opencode-heartbeat 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +170 -0
- package/dist/HeartbeatManager.js +78 -0
- package/dist/HeartbeatSession.js +68 -0
- package/dist/__tests__/HeartbeatManager.test.js +89 -0
- package/dist/__tests__/HeartbeatSession.test.js +107 -0
- package/dist/__tests__/validator.test.js +57 -0
- package/dist/commands/start.js +107 -0
- package/dist/commands/status.js +63 -0
- package/dist/commands/stop.js +19 -0
- package/dist/config.js +9 -0
- package/dist/index.js +237 -0
- package/dist/tui.js +55 -0
- package/dist/types.js +4 -0
- package/dist/utils/file-reader.js +5 -0
- package/dist/utils/validator.js +40 -0
- package/package.json +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# @ali/opencode-heartbeat
|
|
2
|
+
|
|
3
|
+
OpenCode Heartbeat Plugin - 心跳检测和定时任务插件
|
|
4
|
+
|
|
5
|
+
## 版本
|
|
6
|
+
|
|
7
|
+
0.1.0
|
|
8
|
+
|
|
9
|
+
## 项目结构
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
src/
|
|
13
|
+
├── index.ts # 插件入口,自动启动调度器
|
|
14
|
+
├── HeartbeatManager.ts # 心跳会话管理器(单例)
|
|
15
|
+
├── HeartbeatSession.ts # 单个心跳会话
|
|
16
|
+
├── config.ts # 配置加载
|
|
17
|
+
├── types.ts # 类型定义
|
|
18
|
+
│
|
|
19
|
+
├── commands/ # 命令处理
|
|
20
|
+
│ ├── start.ts # 启动心跳命令
|
|
21
|
+
│ ├── stop.ts # 停止心跳命令
|
|
22
|
+
│ └── status.ts # 查看状态命令
|
|
23
|
+
│
|
|
24
|
+
├── utils/ # 工具函数
|
|
25
|
+
│ ├── validator.ts # 文件路径和大小验证
|
|
26
|
+
│ └── file-reader.ts # 文件内容读取
|
|
27
|
+
│
|
|
28
|
+
└── __tests__/ # 单元测试
|
|
29
|
+
├── HeartbeatManager.test.ts
|
|
30
|
+
├── HeartbeatSession.test.ts
|
|
31
|
+
└── validator.test.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 核心功能
|
|
35
|
+
|
|
36
|
+
### 1. 心跳管理
|
|
37
|
+
|
|
38
|
+
- 定时向指定会话发送消息
|
|
39
|
+
- 支持文本和文件两种内容类型
|
|
40
|
+
- 自动清理过期会话(超过3天)
|
|
41
|
+
|
|
42
|
+
### 2. 命令系统
|
|
43
|
+
|
|
44
|
+
| 命令 | 说明 |
|
|
45
|
+
|------|------|
|
|
46
|
+
| `/heartbeat start <seconds> <content>` | 启动心跳(文本内容) |
|
|
47
|
+
| `/heartbeat start <seconds> <file>` | 启动心跳(文件内容) |
|
|
48
|
+
| `/heartbeat stop` | 停止当前会话的心跳 |
|
|
49
|
+
| `/heartbeat status` | 查看当前会话心跳状态 |
|
|
50
|
+
| `/heartbeat status all` | 查看所有心跳会话状态 |
|
|
51
|
+
| `/heartbeat status <sessionId>` | 查看指定会话心跳状态 |
|
|
52
|
+
|
|
53
|
+
### 3. 调度机制
|
|
54
|
+
|
|
55
|
+
- 每秒检查待执行的心跳任务
|
|
56
|
+
- 每分钟清理过期会话
|
|
57
|
+
- 会话超时自动停止(默认3天)
|
|
58
|
+
|
|
59
|
+
## 配置说明
|
|
60
|
+
|
|
61
|
+
### 环境变量
|
|
62
|
+
|
|
63
|
+
| 变量名 | 说明 | 默认值 |
|
|
64
|
+
|--------|------|--------|
|
|
65
|
+
| `HEARTBEAT_MIN_INTERVAL_MS` | 最小心跳间隔(毫秒) | 900000 (15分钟) |
|
|
66
|
+
| `HEARTBEAT_MAX_DURATION_MS` | 最大心跳持续时间(毫秒) | 259200000 (3天) |
|
|
67
|
+
| `HEARTBEAT_MAX_FILE_SIZE_BYTES` | 最大文件大小(字节) | 10240 (10KB) |
|
|
68
|
+
| `HEARTBEAT_WORKSPACE_ROOT` | 工作空间根目录 | process.cwd() |
|
|
69
|
+
|
|
70
|
+
### 配置查找顺序
|
|
71
|
+
|
|
72
|
+
1. 环境变量
|
|
73
|
+
2. 代码默认值
|
|
74
|
+
|
|
75
|
+
## 使用方法
|
|
76
|
+
|
|
77
|
+
### 安装
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### 构建
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run build
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 类型检查
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm run typecheck
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### 运行测试
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
npm test
|
|
99
|
+
npm run test:watch
|
|
100
|
+
npm run test:coverage
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 命令示例
|
|
104
|
+
|
|
105
|
+
### 启动文本心跳
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
/heartbeat start 900 "hello world"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
启动一个每15分钟发送一次 "hello world" 的心跳。
|
|
112
|
+
|
|
113
|
+
### 启动文件心跳
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
/heartbeat start 900 /workspace/notes.txt
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
启动一个每15分钟发送一次文件内容的心跳。
|
|
120
|
+
|
|
121
|
+
### 查看状态
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
/heartbeat status
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
查看当前会话的心跳状态。
|
|
128
|
+
|
|
129
|
+
### 查看所有心跳
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
/heartbeat status all
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
查看所有活跃的心跳会话。
|
|
136
|
+
|
|
137
|
+
### 停止心跳
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
/heartbeat stop
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
停止当前会话的心跳。
|
|
144
|
+
|
|
145
|
+
## 依赖项
|
|
146
|
+
|
|
147
|
+
### dependencies
|
|
148
|
+
|
|
149
|
+
| 包名 | 说明 |
|
|
150
|
+
|------|------|
|
|
151
|
+
| @opencode-ai/plugin | OpenCode插件接口 |
|
|
152
|
+
| @opencode-ai/sdk | OpenCode SDK |
|
|
153
|
+
| zod | 数据验证 |
|
|
154
|
+
|
|
155
|
+
### devDependencies
|
|
156
|
+
|
|
157
|
+
| 包名 | 说明 |
|
|
158
|
+
|------|------|
|
|
159
|
+
| @types/jest | Jest类型定义 |
|
|
160
|
+
| @types/node | Node.js类型定义 |
|
|
161
|
+
| jest | 测试框架 |
|
|
162
|
+
| ts-jest | TypeScript Jest支持 |
|
|
163
|
+
| typescript | TypeScript编译器 |
|
|
164
|
+
| prettier | 代码格式化 |
|
|
165
|
+
| husky | Git钩子 |
|
|
166
|
+
| lint-staged | Git暂存文件检查 |
|
|
167
|
+
|
|
168
|
+
## 仓库
|
|
169
|
+
|
|
170
|
+
- Git: http://gitlab.alibaba-inc.com/gaode.search/opencode-heartbeat.git
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { heartbeatConfig } from "./config.js";
|
|
2
|
+
import { HeartbeatSession } from "./HeartbeatSession.js";
|
|
3
|
+
const MAX_DURATION_MS = heartbeatConfig.maxDurationMs;
|
|
4
|
+
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
|
|
5
|
+
export class HeartbeatManager {
|
|
6
|
+
static instance;
|
|
7
|
+
sessions = new Map();
|
|
8
|
+
constructor() { }
|
|
9
|
+
static getInstance() {
|
|
10
|
+
if (!HeartbeatManager.instance) {
|
|
11
|
+
HeartbeatManager.instance = new HeartbeatManager();
|
|
12
|
+
}
|
|
13
|
+
return HeartbeatManager.instance;
|
|
14
|
+
}
|
|
15
|
+
start(sessionId, intervalMs, content, contentType) {
|
|
16
|
+
if (intervalMs < heartbeatConfig.minIntervalMs) {
|
|
17
|
+
throw new Error(`Interval ${intervalMs}ms is less than minimum ${heartbeatConfig.minIntervalMs}ms`);
|
|
18
|
+
}
|
|
19
|
+
if (this.sessions.has(sessionId)) {
|
|
20
|
+
throw new Error(`Session ${sessionId} already exists`);
|
|
21
|
+
}
|
|
22
|
+
const session = new HeartbeatSession(sessionId, intervalMs, content, contentType);
|
|
23
|
+
this.sessions.set(sessionId, session);
|
|
24
|
+
}
|
|
25
|
+
stop(sessionId) {
|
|
26
|
+
const session = this.sessions.get(sessionId);
|
|
27
|
+
if (!session) {
|
|
28
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
29
|
+
}
|
|
30
|
+
session.stop();
|
|
31
|
+
}
|
|
32
|
+
getStatus(sessionId) {
|
|
33
|
+
if (sessionId) {
|
|
34
|
+
const session = this.sessions.get(sessionId);
|
|
35
|
+
if (!session) {
|
|
36
|
+
throw new Error(`Session ${sessionId} not found`);
|
|
37
|
+
}
|
|
38
|
+
return session.getStatus();
|
|
39
|
+
}
|
|
40
|
+
const sessions = [];
|
|
41
|
+
let runningCount = 0;
|
|
42
|
+
for (const session of this.sessions.values()) {
|
|
43
|
+
const status = session.getStatus();
|
|
44
|
+
sessions.push(status);
|
|
45
|
+
if (status.status === "running") {
|
|
46
|
+
runningCount++;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
sessions,
|
|
51
|
+
totalCount: sessions.length,
|
|
52
|
+
runningCount,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
cleanupExpired() {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
let cleanedCount = 0;
|
|
58
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
59
|
+
const runningDuration = now - session.getStatus().startedAt;
|
|
60
|
+
if (runningDuration > THREE_DAYS_MS) {
|
|
61
|
+
session.stop();
|
|
62
|
+
cleanedCount++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return cleanedCount;
|
|
66
|
+
}
|
|
67
|
+
getRunningSessions() {
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
const runningSessions = [];
|
|
70
|
+
for (const session of this.sessions.values()) {
|
|
71
|
+
const status = session.getStatus();
|
|
72
|
+
if (status.status === "running" && now >= status.nextExecutionAt) {
|
|
73
|
+
runningSessions.push(session);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return runningSessions;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { heartbeatConfig } from "./config.js";
|
|
2
|
+
export class HeartbeatSession {
|
|
3
|
+
state;
|
|
4
|
+
constructor(sessionId, intervalMs, content, contentType) {
|
|
5
|
+
const now = Date.now();
|
|
6
|
+
this.state = {
|
|
7
|
+
sessionId,
|
|
8
|
+
intervalMs,
|
|
9
|
+
content,
|
|
10
|
+
contentType,
|
|
11
|
+
startedAt: now,
|
|
12
|
+
lastExecutedAt: null,
|
|
13
|
+
nextExecutionAt: now,
|
|
14
|
+
executionCount: 0,
|
|
15
|
+
status: "running",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
getNextExecutionTime() {
|
|
19
|
+
return this.state.nextExecutionAt;
|
|
20
|
+
}
|
|
21
|
+
getStatus() {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const runningDurationMs = now - this.state.startedAt;
|
|
24
|
+
return {
|
|
25
|
+
sessionId: this.state.sessionId,
|
|
26
|
+
intervalMs: this.state.intervalMs,
|
|
27
|
+
intervalDisplay: this.formatInterval(this.state.intervalMs),
|
|
28
|
+
contentType: this.state.contentType,
|
|
29
|
+
contentPreview: this.state.content.substring(0, 50),
|
|
30
|
+
startedAt: this.state.startedAt,
|
|
31
|
+
startedAtDisplay: this.formatTimestamp(this.state.startedAt),
|
|
32
|
+
lastExecutedAt: this.state.lastExecutedAt,
|
|
33
|
+
lastExecutedAtDisplay: this.state.lastExecutedAt
|
|
34
|
+
? this.formatTimestamp(this.state.lastExecutedAt)
|
|
35
|
+
: null,
|
|
36
|
+
nextExecutionAt: this.state.nextExecutionAt,
|
|
37
|
+
nextExecutionAtDisplay: this.formatTimestamp(this.state.nextExecutionAt),
|
|
38
|
+
executionCount: this.state.executionCount,
|
|
39
|
+
status: this.state.status,
|
|
40
|
+
runningDurationMs,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
isExpired() {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const runningDuration = now - this.state.startedAt;
|
|
46
|
+
return runningDuration > heartbeatConfig.maxDurationMs;
|
|
47
|
+
}
|
|
48
|
+
markExecuted() {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
this.state.lastExecutedAt = now;
|
|
51
|
+
this.state.nextExecutionAt = now + this.state.intervalMs;
|
|
52
|
+
this.state.executionCount += 1;
|
|
53
|
+
}
|
|
54
|
+
stop() {
|
|
55
|
+
this.state.status = "stopped";
|
|
56
|
+
}
|
|
57
|
+
formatInterval(ms) {
|
|
58
|
+
const minutes = Math.floor(ms / 60000);
|
|
59
|
+
if (minutes >= 60) {
|
|
60
|
+
const hours = Math.floor(minutes / 60);
|
|
61
|
+
return `${hours}小时`;
|
|
62
|
+
}
|
|
63
|
+
return `${minutes}分钟`;
|
|
64
|
+
}
|
|
65
|
+
formatTimestamp(ts) {
|
|
66
|
+
return new Date(ts).toLocaleString("zh-CN");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { HeartbeatManager } from "../HeartbeatManager.js";
|
|
2
|
+
describe("HeartbeatManager", () => {
|
|
3
|
+
let manager;
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
HeartbeatManager.instance = undefined;
|
|
6
|
+
manager = HeartbeatManager.getInstance();
|
|
7
|
+
});
|
|
8
|
+
describe("singleton", () => {
|
|
9
|
+
it("should return same instance", () => {
|
|
10
|
+
const instance1 = HeartbeatManager.getInstance();
|
|
11
|
+
const instance2 = HeartbeatManager.getInstance();
|
|
12
|
+
expect(instance1).toBe(instance2);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("start", () => {
|
|
16
|
+
it("should create a new session", () => {
|
|
17
|
+
manager.start("test-session-1", 900000, "test content", "text");
|
|
18
|
+
const status = manager.getStatus("test-session-1");
|
|
19
|
+
expect(status.sessionId).toBe("test-session-1");
|
|
20
|
+
expect(status.status).toBe("running");
|
|
21
|
+
});
|
|
22
|
+
it("should throw when interval is less than minimum", () => {
|
|
23
|
+
expect(() => {
|
|
24
|
+
manager.start("test-session", 1000, "test content", "text");
|
|
25
|
+
}).toThrow(/less than minimum/);
|
|
26
|
+
});
|
|
27
|
+
it("should throw when session already exists", () => {
|
|
28
|
+
manager.start("test-session", 900000, "test content", "text");
|
|
29
|
+
expect(() => {
|
|
30
|
+
manager.start("test-session", 900000, "test content", "text");
|
|
31
|
+
}).toThrow(/already exists/);
|
|
32
|
+
});
|
|
33
|
+
it("should accept file content type", () => {
|
|
34
|
+
manager.start("test-session", 900000, "/path/to/file.txt", "file");
|
|
35
|
+
const status = manager.getStatus("test-session");
|
|
36
|
+
expect(status.contentType).toBe("file");
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe("stop", () => {
|
|
40
|
+
it("should stop a running session", () => {
|
|
41
|
+
manager.start("test-session", 900000, "test content", "text");
|
|
42
|
+
manager.stop("test-session");
|
|
43
|
+
const status = manager.getStatus("test-session");
|
|
44
|
+
expect(status.status).toBe("stopped");
|
|
45
|
+
});
|
|
46
|
+
it("should throw when session not found", () => {
|
|
47
|
+
expect(() => {
|
|
48
|
+
manager.stop("non-existent");
|
|
49
|
+
}).toThrow(/not found/);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
describe("getStatus", () => {
|
|
53
|
+
it("should return single session status", () => {
|
|
54
|
+
manager.start("test-session", 900000, "test content", "text");
|
|
55
|
+
const status = manager.getStatus("test-session");
|
|
56
|
+
expect(status).toHaveProperty("sessionId");
|
|
57
|
+
expect(status).toHaveProperty("intervalMs");
|
|
58
|
+
expect(status).toHaveProperty("status");
|
|
59
|
+
expect(status).toHaveProperty("startedAt");
|
|
60
|
+
});
|
|
61
|
+
it("should return all sessions status", () => {
|
|
62
|
+
manager.start("session-1", 900000, "content 1", "text");
|
|
63
|
+
manager.start("session-2", 1800000, "content 2", "text");
|
|
64
|
+
const status = manager.getStatus();
|
|
65
|
+
expect(status).toHaveProperty("sessions");
|
|
66
|
+
expect(status).toHaveProperty("totalCount");
|
|
67
|
+
expect(status).toHaveProperty("runningCount");
|
|
68
|
+
expect(status.totalCount).toBe(2);
|
|
69
|
+
expect(status.runningCount).toBe(2);
|
|
70
|
+
});
|
|
71
|
+
it("should throw when session not found", () => {
|
|
72
|
+
expect(() => {
|
|
73
|
+
manager.getStatus("non-existent");
|
|
74
|
+
}).toThrow(/not found/);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
describe("cleanupExpired", () => {
|
|
78
|
+
it("should return 0 when no sessions", () => {
|
|
79
|
+
const count = manager.cleanupExpired();
|
|
80
|
+
expect(count).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe("getRunningSessions", () => {
|
|
84
|
+
it("should return empty array when no sessions", () => {
|
|
85
|
+
const sessions = manager.getRunningSessions();
|
|
86
|
+
expect(sessions).toHaveLength(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { HeartbeatSession } from "../HeartbeatSession.js";
|
|
2
|
+
describe("HeartbeatSession", () => {
|
|
3
|
+
let session;
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
session = new HeartbeatSession("test-session", 900000, "test content", "text");
|
|
6
|
+
});
|
|
7
|
+
describe("constructor", () => {
|
|
8
|
+
it("should initialize with running status", () => {
|
|
9
|
+
const status = session.getStatus();
|
|
10
|
+
expect(status.status).toBe("running");
|
|
11
|
+
});
|
|
12
|
+
it("should set sessionId correctly", () => {
|
|
13
|
+
const status = session.getStatus();
|
|
14
|
+
expect(status.sessionId).toBe("test-session");
|
|
15
|
+
});
|
|
16
|
+
it("should set intervalMs correctly", () => {
|
|
17
|
+
const status = session.getStatus();
|
|
18
|
+
expect(status.intervalMs).toBe(900000);
|
|
19
|
+
});
|
|
20
|
+
it("should set content correctly", () => {
|
|
21
|
+
const status = session.getStatus();
|
|
22
|
+
expect(status.contentPreview).toBe("test content");
|
|
23
|
+
});
|
|
24
|
+
it("should set contentType correctly", () => {
|
|
25
|
+
const status = session.getStatus();
|
|
26
|
+
expect(status.contentType).toBe("text");
|
|
27
|
+
});
|
|
28
|
+
it("should initialize executionCount to 0", () => {
|
|
29
|
+
const status = session.getStatus();
|
|
30
|
+
expect(status.executionCount).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe("getNextExecutionTime", () => {
|
|
34
|
+
it("should return next execution timestamp", () => {
|
|
35
|
+
const nextExec = session.getNextExecutionTime();
|
|
36
|
+
expect(typeof nextExec).toBe("number");
|
|
37
|
+
expect(nextExec).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe("getStatus", () => {
|
|
41
|
+
it("should return status with all required fields", () => {
|
|
42
|
+
const status = session.getStatus();
|
|
43
|
+
expect(status).toHaveProperty("sessionId");
|
|
44
|
+
expect(status).toHaveProperty("intervalMs");
|
|
45
|
+
expect(status).toHaveProperty("intervalDisplay");
|
|
46
|
+
expect(status).toHaveProperty("contentType");
|
|
47
|
+
expect(status).toHaveProperty("contentPreview");
|
|
48
|
+
expect(status).toHaveProperty("startedAt");
|
|
49
|
+
expect(status).toHaveProperty("startedAtDisplay");
|
|
50
|
+
expect(status).toHaveProperty("lastExecutedAt");
|
|
51
|
+
expect(status).toHaveProperty("lastExecutedAtDisplay");
|
|
52
|
+
expect(status).toHaveProperty("nextExecutionAt");
|
|
53
|
+
expect(status).toHaveProperty("nextExecutionAtDisplay");
|
|
54
|
+
expect(status).toHaveProperty("executionCount");
|
|
55
|
+
expect(status).toHaveProperty("status");
|
|
56
|
+
expect(status).toHaveProperty("runningDurationMs");
|
|
57
|
+
});
|
|
58
|
+
it("should return null for lastExecutedAt initially", () => {
|
|
59
|
+
const status = session.getStatus();
|
|
60
|
+
expect(status.lastExecutedAt).toBeNull();
|
|
61
|
+
expect(status.lastExecutedAtDisplay).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
describe("isExpired", () => {
|
|
65
|
+
it("should return false for new session", () => {
|
|
66
|
+
expect(session.isExpired()).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("markExecuted", () => {
|
|
70
|
+
it("should increment executionCount", () => {
|
|
71
|
+
session.markExecuted();
|
|
72
|
+
const status = session.getStatus();
|
|
73
|
+
expect(status.executionCount).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
it("should update lastExecutedAt", () => {
|
|
76
|
+
session.markExecuted();
|
|
77
|
+
const status = session.getStatus();
|
|
78
|
+
expect(status.lastExecutedAt).not.toBeNull();
|
|
79
|
+
});
|
|
80
|
+
it("should update nextExecutionAt", () => {
|
|
81
|
+
const before = session.getNextExecutionTime();
|
|
82
|
+
session.markExecuted();
|
|
83
|
+
const after = session.getNextExecutionTime();
|
|
84
|
+
expect(after).toBeGreaterThan(before);
|
|
85
|
+
});
|
|
86
|
+
it("should accumulate execution count", () => {
|
|
87
|
+
session.markExecuted();
|
|
88
|
+
session.markExecuted();
|
|
89
|
+
session.markExecuted();
|
|
90
|
+
const status = session.getStatus();
|
|
91
|
+
expect(status.executionCount).toBe(3);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("stop", () => {
|
|
95
|
+
it("should change status to stopped", () => {
|
|
96
|
+
session.stop();
|
|
97
|
+
const status = session.getStatus();
|
|
98
|
+
expect(status.status).toBe("stopped");
|
|
99
|
+
});
|
|
100
|
+
it("should prevent multiple stops", () => {
|
|
101
|
+
session.stop();
|
|
102
|
+
session.stop();
|
|
103
|
+
const status = session.getStatus();
|
|
104
|
+
expect(status.status).toBe("stopped");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { validateFilePath, validateFileSize, } from "../utils/validator.js";
|
|
4
|
+
describe("validator", () => {
|
|
5
|
+
const testWorkspaceRoot = "/tmp/test-workspace";
|
|
6
|
+
beforeAll(async () => {
|
|
7
|
+
await fs.mkdir(testWorkspaceRoot, { recursive: true });
|
|
8
|
+
});
|
|
9
|
+
afterAll(async () => {
|
|
10
|
+
await fs.rm(testWorkspaceRoot, { recursive: true, force: true });
|
|
11
|
+
});
|
|
12
|
+
describe("validateFilePath", () => {
|
|
13
|
+
it("should return true for valid file path", () => {
|
|
14
|
+
const result = validateFilePath("/tmp/test-workspace/file.txt", testWorkspaceRoot);
|
|
15
|
+
expect(result).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it("should return false for path with parent directory traversal", () => {
|
|
18
|
+
const result = validateFilePath("/tmp/test-workspace/../../../etc/passwd", testWorkspaceRoot);
|
|
19
|
+
expect(result).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
it("should return false for path outside workspace", () => {
|
|
22
|
+
const result = validateFilePath("/tmp/other-workspace/file.txt", testWorkspaceRoot);
|
|
23
|
+
expect(result).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it("should return true for nested path within workspace", () => {
|
|
26
|
+
const result = validateFilePath("/tmp/test-workspace/subdir/nested/file.txt", testWorkspaceRoot);
|
|
27
|
+
expect(result).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe("validateFileSize", () => {
|
|
31
|
+
it("should return valid for file under max size", async () => {
|
|
32
|
+
const testFile = path.join(testWorkspaceRoot, "small.txt");
|
|
33
|
+
await fs.writeFile(testFile, "small content");
|
|
34
|
+
const result = await validateFileSize(testFile, 10240);
|
|
35
|
+
expect(result.valid).toBe(true);
|
|
36
|
+
expect(result.size).toBeLessThanOrEqual(10240);
|
|
37
|
+
});
|
|
38
|
+
it("should return invalid for file exceeding max size", async () => {
|
|
39
|
+
const testFile = path.join(testWorkspaceRoot, "large.txt");
|
|
40
|
+
const largeContent = "x".repeat(20000);
|
|
41
|
+
await fs.writeFile(testFile, largeContent);
|
|
42
|
+
const result = await validateFileSize(testFile, 10240);
|
|
43
|
+
expect(result.valid).toBe(false);
|
|
44
|
+
expect(result.error).toContain("exceeds maximum");
|
|
45
|
+
});
|
|
46
|
+
it("should return invalid for non-existent file", async () => {
|
|
47
|
+
const result = await validateFileSize("/tmp/test-workspace/non-existent.txt", 10240);
|
|
48
|
+
expect(result.valid).toBe(false);
|
|
49
|
+
expect(result.error).toContain("Cannot access file");
|
|
50
|
+
});
|
|
51
|
+
it("should return invalid for directory", async () => {
|
|
52
|
+
const result = await validateFileSize(testWorkspaceRoot, 10240);
|
|
53
|
+
expect(result.valid).toBe(false);
|
|
54
|
+
expect(result.error).toBe("Path is not a file");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { HeartbeatManager } from "../HeartbeatManager.js";
|
|
2
|
+
import { validateFilePath, validateFileSize } from "../utils/validator.js";
|
|
3
|
+
import { readFileContent } from "../utils/file-reader.js";
|
|
4
|
+
import { heartbeatConfig } from "../config.js";
|
|
5
|
+
const MIN_INTERVAL_SECONDS = heartbeatConfig.minIntervalMs / 1000;
|
|
6
|
+
/**
|
|
7
|
+
* Parse heartbeat start command arguments
|
|
8
|
+
* Format: "<seconds> <content>" or "<seconds> \"<text>\""
|
|
9
|
+
*/
|
|
10
|
+
function parseStartArgs(args) {
|
|
11
|
+
if (args.length < 2) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const intervalSeconds = parseInt(args[0], 10);
|
|
15
|
+
if (isNaN(intervalSeconds) || intervalSeconds <= 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
// Join remaining args as content (supports quoted strings)
|
|
19
|
+
const content = args.slice(1).join(" ");
|
|
20
|
+
return {
|
|
21
|
+
intervalSeconds,
|
|
22
|
+
content,
|
|
23
|
+
isFile: !content.startsWith('"'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Format interval in human-readable format
|
|
28
|
+
*/
|
|
29
|
+
function formatInterval(ms) {
|
|
30
|
+
const seconds = Math.floor(ms / 1000);
|
|
31
|
+
const minutes = Math.floor(seconds / 60);
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
const days = Math.floor(hours / 24);
|
|
34
|
+
if (days > 0)
|
|
35
|
+
return `${days}d ${hours % 24}h`;
|
|
36
|
+
if (hours > 0)
|
|
37
|
+
return `${hours}h ${minutes % 60}m`;
|
|
38
|
+
if (minutes > 0)
|
|
39
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
40
|
+
return `${seconds}s`;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Handle heartbeat start command
|
|
44
|
+
*/
|
|
45
|
+
export async function handleStart(args, sessionId) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = parseStartArgs(args);
|
|
48
|
+
if (!parsed) {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
message: `Usage: /heartbeat start <seconds> <content|file>
|
|
52
|
+
Example: /heartbeat start 900 "hello"
|
|
53
|
+
Example: /heartbeat start 900 test.txt`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const { intervalSeconds, content, isFile } = parsed;
|
|
57
|
+
// Validate interval
|
|
58
|
+
if (intervalSeconds < MIN_INTERVAL_SECONDS) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
message: `Interval must be at least ${MIN_INTERVAL_SECONDS} seconds (${MIN_INTERVAL_SECONDS / 60} minutes)`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const intervalMs = intervalSeconds * 1000;
|
|
65
|
+
let finalContent;
|
|
66
|
+
let contentType;
|
|
67
|
+
// Handle file or text content
|
|
68
|
+
if (isFile) {
|
|
69
|
+
const filePath = content;
|
|
70
|
+
// Validate file path
|
|
71
|
+
if (!validateFilePath(filePath, heartbeatConfig.workspaceRoot)) {
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
message: `Invalid file path: ${filePath}. Path must be within workspace`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// Validate file size
|
|
78
|
+
const sizeValidation = await validateFileSize(filePath, heartbeatConfig.maxFileSizeBytes);
|
|
79
|
+
if (!sizeValidation.valid) {
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
message: `File validation failed: ${sizeValidation.error}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// Read file content
|
|
86
|
+
finalContent = await readFileContent(filePath);
|
|
87
|
+
contentType = "file";
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
// Remove quotes from text content
|
|
91
|
+
finalContent = content.replace(/^"|"$/g, "");
|
|
92
|
+
contentType = "text";
|
|
93
|
+
}
|
|
94
|
+
// Start heartbeat
|
|
95
|
+
HeartbeatManager.getInstance().start(sessionId, intervalMs, finalContent, contentType);
|
|
96
|
+
return {
|
|
97
|
+
success: true,
|
|
98
|
+
message: `Heartbeat started: interval=${formatInterval(intervalMs)}, contentType=${contentType}`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
success: false,
|
|
104
|
+
message: `Failed to start heartbeat: ${error.message}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { HeartbeatManager } from "../HeartbeatManager.js";
|
|
2
|
+
function formatTimestamp(timestamp) {
|
|
3
|
+
return new Date(timestamp).toISOString();
|
|
4
|
+
}
|
|
5
|
+
function formatStatusItem(status) {
|
|
6
|
+
return `
|
|
7
|
+
Session: ${status.sessionId}
|
|
8
|
+
Status: ${status.status}
|
|
9
|
+
Interval: ${status.intervalDisplay}
|
|
10
|
+
Content Type: ${status.contentType}
|
|
11
|
+
Content Preview: ${status.contentPreview}
|
|
12
|
+
Started: ${status.startedAtDisplay}
|
|
13
|
+
Last Executed: ${status.lastExecutedAtDisplay || "N/A"}
|
|
14
|
+
Next Execution: ${status.nextExecutionAtDisplay}
|
|
15
|
+
Execution Count: ${status.executionCount}
|
|
16
|
+
`;
|
|
17
|
+
}
|
|
18
|
+
function formatAllStatus(status) {
|
|
19
|
+
const lines = [
|
|
20
|
+
`Total: ${status.totalCount}`,
|
|
21
|
+
`Running: ${status.runningCount}`,
|
|
22
|
+
"",
|
|
23
|
+
];
|
|
24
|
+
for (const session of status.sessions) {
|
|
25
|
+
lines.push(formatStatusItem(session));
|
|
26
|
+
lines.push("---");
|
|
27
|
+
}
|
|
28
|
+
return lines.join("\n");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Handle heartbeat status command
|
|
32
|
+
* Args: [] = current session, ["all"] = all sessions, [sessionId] = specific session
|
|
33
|
+
*/
|
|
34
|
+
export function handleStatus(args, sessionId) {
|
|
35
|
+
try {
|
|
36
|
+
if (args.length === 0) {
|
|
37
|
+
const status = HeartbeatManager.getInstance().getStatus(sessionId);
|
|
38
|
+
return {
|
|
39
|
+
success: true,
|
|
40
|
+
message: formatStatusItem(status),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (args[0] === "all") {
|
|
44
|
+
const status = HeartbeatManager.getInstance().getStatus();
|
|
45
|
+
return {
|
|
46
|
+
success: true,
|
|
47
|
+
message: formatAllStatus(status),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const targetSessionId = args[0];
|
|
51
|
+
const status = HeartbeatManager.getInstance().getStatus(targetSessionId);
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
message: formatStatusItem(status),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return {
|
|
59
|
+
success: false,
|
|
60
|
+
message: `Failed to get status: ${error.message}`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { HeartbeatManager } from "../HeartbeatManager.js";
|
|
2
|
+
/**
|
|
3
|
+
* Handle heartbeat stop command
|
|
4
|
+
*/
|
|
5
|
+
export function handleStop(_args, sessionId) {
|
|
6
|
+
try {
|
|
7
|
+
HeartbeatManager.getInstance().stop(sessionId);
|
|
8
|
+
return {
|
|
9
|
+
success: true,
|
|
10
|
+
message: "Heartbeat stopped",
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
return {
|
|
15
|
+
success: false,
|
|
16
|
+
message: `Failed to stop heartbeat: ${error.message}`,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function loadConfig() {
|
|
2
|
+
return {
|
|
3
|
+
minIntervalMs: parseInt(process.env.HEARTBEAT_MIN_INTERVAL_MS || "900000", 10),
|
|
4
|
+
maxDurationMs: parseInt(process.env.HEARTBEAT_MAX_DURATION_MS || "259200000", 10),
|
|
5
|
+
maxFileSizeBytes: parseInt(process.env.HEARTBEAT_MAX_FILE_SIZE_BYTES || "10240", 10),
|
|
6
|
+
workspaceRoot: process.env.HEARTBEAT_WORKSPACE_ROOT || process.cwd(),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
export const heartbeatConfig = loadConfig();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { handleStart } from "./commands/start.js";
|
|
4
|
+
import { handleStop } from "./commands/stop.js";
|
|
5
|
+
import { handleStatus } from "./commands/status.js";
|
|
6
|
+
import { HeartbeatManager } from "./HeartbeatManager.js";
|
|
7
|
+
let schedulerInterval = null;
|
|
8
|
+
let cleanupInterval = null;
|
|
9
|
+
let pluginClient = null;
|
|
10
|
+
function startScheduler(client) {
|
|
11
|
+
pluginClient = client;
|
|
12
|
+
schedulerInterval = setInterval(async () => {
|
|
13
|
+
try {
|
|
14
|
+
const sessions = HeartbeatManager.getInstance().getRunningSessions();
|
|
15
|
+
for (const session of sessions) {
|
|
16
|
+
const status = session.getStatus();
|
|
17
|
+
try {
|
|
18
|
+
let content = status.contentPreview;
|
|
19
|
+
if (status.contentType === "file") {
|
|
20
|
+
content = status.contentPreview;
|
|
21
|
+
}
|
|
22
|
+
if (pluginClient && status.sessionId) {
|
|
23
|
+
try {
|
|
24
|
+
await pluginClient.session.promptAsync({
|
|
25
|
+
path: { id: status.sessionId },
|
|
26
|
+
body: {
|
|
27
|
+
parts: [{ type: "text", text: `[Heartbeat] ${content}` }],
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
console.error("[heartbeat] Failed to send message:", err);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
session.markExecuted();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error(`[heartbeat] Failed to send to session ${status.sessionId}:`, error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
console.error("[heartbeat] Scheduler error:", error);
|
|
44
|
+
}
|
|
45
|
+
}, 1000);
|
|
46
|
+
cleanupInterval = setInterval(() => {
|
|
47
|
+
try {
|
|
48
|
+
const cleaned = HeartbeatManager.getInstance().cleanupExpired();
|
|
49
|
+
if (cleaned > 0) {
|
|
50
|
+
console.log(`[heartbeat] Cleaned up ${cleaned} expired sessions`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
console.error("[heartbeat] Cleanup error:", error);
|
|
55
|
+
}
|
|
56
|
+
}, 60000);
|
|
57
|
+
}
|
|
58
|
+
function stopScheduler() {
|
|
59
|
+
if (schedulerInterval) {
|
|
60
|
+
clearInterval(schedulerInterval);
|
|
61
|
+
schedulerInterval = null;
|
|
62
|
+
}
|
|
63
|
+
if (cleanupInterval) {
|
|
64
|
+
clearInterval(cleanupInterval);
|
|
65
|
+
cleanupInterval = null;
|
|
66
|
+
}
|
|
67
|
+
pluginClient = null;
|
|
68
|
+
}
|
|
69
|
+
function isHeartbeatCommand(command) {
|
|
70
|
+
const lower = command.toLowerCase();
|
|
71
|
+
return (lower === "heartbeat" ||
|
|
72
|
+
lower.startsWith("heartbeat-") ||
|
|
73
|
+
lower === "hb" ||
|
|
74
|
+
lower.startsWith("hb-"));
|
|
75
|
+
}
|
|
76
|
+
function generateHeartbeatResponse(command, args) {
|
|
77
|
+
const cmd = command.toLowerCase();
|
|
78
|
+
if (cmd === "heartbeat" && !args) {
|
|
79
|
+
return `# /heartbeat Command
|
|
80
|
+
|
|
81
|
+
**Description**: Heartbeat plugin - send periodic messages to sessions
|
|
82
|
+
|
|
83
|
+
**Available Commands**:
|
|
84
|
+
- \`/heartbeat-start\` - Start heartbeat
|
|
85
|
+
- \`/heartbeat-stop\` - Stop current session's heartbeat
|
|
86
|
+
- \`/heartbeat-status\` - View heartbeat status
|
|
87
|
+
|
|
88
|
+
**Usage**:
|
|
89
|
+
Use the \`heartbeat-control\` tool:
|
|
90
|
+
|
|
91
|
+
\`\`\`
|
|
92
|
+
heartbeat-control start --seconds 900 --content "hello world"
|
|
93
|
+
heartbeat-control stop
|
|
94
|
+
heartbeat-control status
|
|
95
|
+
\`\`\`
|
|
96
|
+
|
|
97
|
+
**Note**: Minimum interval is 900 seconds (15 minutes).
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
if (cmd === "heartbeat-start" || cmd === "hb-start") {
|
|
101
|
+
return `# /heartbeat-start Command
|
|
102
|
+
|
|
103
|
+
**Description**: Start a heartbeat that sends periodic messages to the session
|
|
104
|
+
|
|
105
|
+
**Usage**: \`heartbeat-control start --seconds <seconds> --content <content|file>\`
|
|
106
|
+
|
|
107
|
+
**Examples**:
|
|
108
|
+
\`\`\`
|
|
109
|
+
heartbeat-control start --seconds 900 --content "hello world"
|
|
110
|
+
heartbeat-control start --seconds 900 --content /workspace/notes.txt
|
|
111
|
+
\`\`\`
|
|
112
|
+
|
|
113
|
+
**Parameters**:
|
|
114
|
+
- \`seconds\`: Interval in seconds (minimum 900)
|
|
115
|
+
- \`content\`: Text content or file path
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
if (cmd === "heartbeat-stop" || cmd === "hb-stop" || cmd === "hb") {
|
|
119
|
+
return `# /heartbeat-stop Command
|
|
120
|
+
|
|
121
|
+
**Description**: Stop the current session's heartbeat
|
|
122
|
+
|
|
123
|
+
**Usage**: \`heartbeat-control stop\`
|
|
124
|
+
`;
|
|
125
|
+
}
|
|
126
|
+
if (cmd === "heartbeat-status" || cmd === "hb-status") {
|
|
127
|
+
return `# /heartbeat-status Command
|
|
128
|
+
|
|
129
|
+
**Description**: View heartbeat status
|
|
130
|
+
|
|
131
|
+
**Usage**:
|
|
132
|
+
- \`heartbeat-control status\` - Current session status
|
|
133
|
+
- \`heartbeat-control status --sessionId <id>\` - Specific session
|
|
134
|
+
- \`heartbeat-control status --sessionId all\` - All sessions
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
return `# /heartbeat Command
|
|
138
|
+
|
|
139
|
+
**Description**: Heartbeat plugin - command not found
|
|
140
|
+
|
|
141
|
+
Use \`/heartbeat\` without arguments to see available commands.
|
|
142
|
+
`;
|
|
143
|
+
}
|
|
144
|
+
export const OpenCodeHeartbeatPlugin = async ({ client, directory, }) => {
|
|
145
|
+
startScheduler(client);
|
|
146
|
+
const processedCommands = new Set();
|
|
147
|
+
return {
|
|
148
|
+
tool: {
|
|
149
|
+
"heartbeat-control": tool({
|
|
150
|
+
description: "Control heartbeat - start/stop/status for session",
|
|
151
|
+
args: {
|
|
152
|
+
command: z
|
|
153
|
+
.enum(["start", "stop", "status"])
|
|
154
|
+
.describe("Command to execute"),
|
|
155
|
+
seconds: z
|
|
156
|
+
.number()
|
|
157
|
+
.optional()
|
|
158
|
+
.describe("Interval in seconds (for start)"),
|
|
159
|
+
content: z
|
|
160
|
+
.string()
|
|
161
|
+
.optional()
|
|
162
|
+
.describe("Content or file path (for start)"),
|
|
163
|
+
sessionId: z
|
|
164
|
+
.string()
|
|
165
|
+
.optional()
|
|
166
|
+
.describe("Session ID (optional, defaults to current)"),
|
|
167
|
+
},
|
|
168
|
+
async execute({ command, seconds, content, sessionId }) {
|
|
169
|
+
const currentSessionId = sessionId || "default-session";
|
|
170
|
+
switch (command) {
|
|
171
|
+
case "start": {
|
|
172
|
+
if (!seconds || !content) {
|
|
173
|
+
return "Usage: heartbeat-control start --seconds <seconds> --content <content|file>";
|
|
174
|
+
}
|
|
175
|
+
const result = await handleStart([String(seconds), content], currentSessionId);
|
|
176
|
+
return result.message;
|
|
177
|
+
}
|
|
178
|
+
case "stop": {
|
|
179
|
+
const result = handleStop([], currentSessionId);
|
|
180
|
+
return result.message;
|
|
181
|
+
}
|
|
182
|
+
case "status": {
|
|
183
|
+
const result = handleStatus([], currentSessionId);
|
|
184
|
+
return result.message;
|
|
185
|
+
}
|
|
186
|
+
default:
|
|
187
|
+
return `Unknown command: ${command}`;
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
}),
|
|
191
|
+
},
|
|
192
|
+
"chat.message": async (input, output) => {
|
|
193
|
+
console.log("[heartbeat] chat.message hook fired", {
|
|
194
|
+
sessionID: input.sessionID,
|
|
195
|
+
partsCount: output.parts.length,
|
|
196
|
+
});
|
|
197
|
+
const messageText = output.parts
|
|
198
|
+
.filter((p) => p.type === "text")
|
|
199
|
+
.map((p) => p.text.trim())
|
|
200
|
+
.join(" ");
|
|
201
|
+
console.log("[heartbeat] messageText:", messageText);
|
|
202
|
+
if (!messageText.startsWith("/")) {
|
|
203
|
+
console.log("[heartbeat] not a command (no / prefix)");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const command = messageText.slice(1).split(/\s+/)[0];
|
|
207
|
+
const args = messageText.slice(1).split(/\s+/).slice(1).join(" ");
|
|
208
|
+
console.log("[heartbeat] parsed command:", command, "args:", args);
|
|
209
|
+
if (!isHeartbeatCommand(command)) {
|
|
210
|
+
console.log("[heartbeat] not a heartbeat command");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const commandKey = `${input.sessionID}:${command}:${args}`;
|
|
214
|
+
if (processedCommands.has(commandKey)) {
|
|
215
|
+
console.log("[heartbeat] already processed, skipping");
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
processedCommands.add(commandKey);
|
|
219
|
+
console.log("[heartbeat] generating response for:", command);
|
|
220
|
+
const response = generateHeartbeatResponse(command, args);
|
|
221
|
+
output.parts = [
|
|
222
|
+
{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: response,
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
console.log("[heartbeat] response set, parts replaced");
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
};
|
|
231
|
+
export default OpenCodeHeartbeatPlugin;
|
|
232
|
+
async function shutdown() {
|
|
233
|
+
stopScheduler();
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
236
|
+
process.on("SIGINT", shutdown);
|
|
237
|
+
process.on("SIGTERM", shutdown);
|
package/dist/tui.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
async function tuiHeartbeatPlugin(api, _options, _meta) {
|
|
2
|
+
api.command.register(() => [
|
|
3
|
+
{
|
|
4
|
+
title: "Start Heartbeat",
|
|
5
|
+
value: "heartbeat-start",
|
|
6
|
+
description: "启动心跳 (间隔至少15分钟)",
|
|
7
|
+
category: "Heartbeat",
|
|
8
|
+
slash: {
|
|
9
|
+
name: "heartbeat",
|
|
10
|
+
},
|
|
11
|
+
onSelect: () => {
|
|
12
|
+
api.ui.toast({
|
|
13
|
+
variant: "info",
|
|
14
|
+
message: "Usage: /heartbeat start <seconds> <content>",
|
|
15
|
+
});
|
|
16
|
+
api.command.show();
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
title: "Stop Heartbeat",
|
|
21
|
+
value: "heartbeat-stop",
|
|
22
|
+
description: "停止当前session的心跳",
|
|
23
|
+
category: "Heartbeat",
|
|
24
|
+
slash: {
|
|
25
|
+
name: "heartbeat-stop",
|
|
26
|
+
aliases: ["hb-stop"],
|
|
27
|
+
},
|
|
28
|
+
onSelect: async () => {
|
|
29
|
+
api.ui.toast({
|
|
30
|
+
variant: "info",
|
|
31
|
+
message: "Use tool: heartbeat-control stop",
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
title: "Heartbeat Status",
|
|
37
|
+
value: "heartbeat-status",
|
|
38
|
+
description: "查看心跳状态",
|
|
39
|
+
category: "Heartbeat",
|
|
40
|
+
slash: {
|
|
41
|
+
name: "heartbeat-status",
|
|
42
|
+
aliases: ["hb-status"],
|
|
43
|
+
},
|
|
44
|
+
onSelect: async () => {
|
|
45
|
+
api.ui.toast({
|
|
46
|
+
variant: "info",
|
|
47
|
+
message: "Use tool: heartbeat-control status",
|
|
48
|
+
});
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
]);
|
|
52
|
+
}
|
|
53
|
+
export default {
|
|
54
|
+
tui: tuiHeartbeatPlugin,
|
|
55
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
export function validateFilePath(filePath, workspaceRoot) {
|
|
4
|
+
const absolutePath = path.resolve(filePath);
|
|
5
|
+
const absoluteWorkspace = path.resolve(workspaceRoot);
|
|
6
|
+
if (absolutePath.includes("..")) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return absolutePath.startsWith(absoluteWorkspace);
|
|
10
|
+
}
|
|
11
|
+
export async function validateFileSize(filePath, maxSizeBytes) {
|
|
12
|
+
try {
|
|
13
|
+
const stats = await fs.stat(filePath);
|
|
14
|
+
if (!stats.isFile()) {
|
|
15
|
+
return {
|
|
16
|
+
valid: false,
|
|
17
|
+
size: 0,
|
|
18
|
+
error: "Path is not a file",
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (stats.size > maxSizeBytes) {
|
|
22
|
+
return {
|
|
23
|
+
valid: false,
|
|
24
|
+
size: stats.size,
|
|
25
|
+
error: `File size ${stats.size} exceeds maximum ${maxSizeBytes}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
valid: true,
|
|
30
|
+
size: stats.size,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
return {
|
|
35
|
+
valid: false,
|
|
36
|
+
size: 0,
|
|
37
|
+
error: `Cannot access file: ${error.message}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "0.0.9",
|
|
3
|
+
"description": "OpenCode Heartbeat Plugin - 心跳检测和定时任务插件",
|
|
4
|
+
"main": "./dist/index.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"import": "./dist/index.js",
|
|
9
|
+
"types": "./dist/index.d.ts"
|
|
10
|
+
},
|
|
11
|
+
"./tui": {
|
|
12
|
+
"import": "./dist/tui.js",
|
|
13
|
+
"types": "./dist/tui.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
23
|
+
"test": "jest",
|
|
24
|
+
"test:watch": "jest --watch",
|
|
25
|
+
"test:coverage": "jest --coverage"
|
|
26
|
+
},
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "http://gitlab.alibaba-inc.com/gaode.search/opencode-heartbeat.git"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"registry": "https://registry.anpm.alibaba-inc.com"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"opencode-heartbeat",
|
|
36
|
+
"opencode",
|
|
37
|
+
"plugin",
|
|
38
|
+
"heartbeat",
|
|
39
|
+
"cron"
|
|
40
|
+
],
|
|
41
|
+
"author": "",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@opencode-ai/plugin": "1.1.53",
|
|
45
|
+
"@opencode-ai/sdk": "1.1.53",
|
|
46
|
+
"zod": "4.1.8"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/jest": "^30.0.0",
|
|
50
|
+
"@types/node": "^22.13.9",
|
|
51
|
+
"husky": "^8.0.3",
|
|
52
|
+
"jest": "^30.3.0",
|
|
53
|
+
"lint-staged": "^13.2.3",
|
|
54
|
+
"prettier": "^3.0.2",
|
|
55
|
+
"ts-jest": "^29.4.6",
|
|
56
|
+
"typescript": "^5.8.2"
|
|
57
|
+
},
|
|
58
|
+
"name": "opencode-heartbeat"
|
|
59
|
+
}
|