ohmyvibe 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -2
- package/README.md +249 -249
- package/dist/cli.js +20 -20
- package/dist/daemon/managementBridge.js +2 -0
- package/dist/daemon/sessionManager.js +218 -27
- package/dist/daemon/sessionRuntime.js +108 -2
- package/dist/daemon/sessionStore.js +24 -24
- package/package.json +46 -46
package/.env.example
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
MANAGEMENT_SERVER_URL=http://localhost:3310
|
|
2
|
-
DAEMON_NAME=ohmyvibe-local
|
|
1
|
+
MANAGEMENT_SERVER_URL=http://localhost:3310
|
|
2
|
+
DAEMON_NAME=ohmyvibe-local
|
package/README.md
CHANGED
|
@@ -1,249 +1,249 @@
|
|
|
1
|
-
# OhMyVibe
|
|
2
|
-
|
|
3
|
-
这是一个 `VibeCoding` 控制台:
|
|
4
|
-
|
|
5
|
-
- `daemon -> control server <- browser` 架构(daemon 主动连接管理端)
|
|
6
|
-
- 每个会话独立启动一个 `codex app-server` 子进程
|
|
7
|
-
- daemon 统一管理多会话、消息发送、中断、状态同步
|
|
8
|
-
- 额外提供一个标准 `ACP` bridge 入口,方便后续给编辑器或其他 ACP client 接入
|
|
9
|
-
- 应用侧 session 本地持久化到 `data/sessions.json`
|
|
10
|
-
- 支持从 Codex 历史 `~/.codex/sessions` 恢复会话,并绑定到原始 Codex thread
|
|
11
|
-
- Web 控制台为独立 React + shadcn 风格项目,浏览器只连接管理端
|
|
12
|
-
|
|
13
|
-
## 为什么这样做
|
|
14
|
-
|
|
15
|
-
当前官方能力里,`Codex CLI` 暴露的是 `app-server` 自动化接口,而不是原生 `ACP agent`。因此这个 MVP 采用两层桥接:
|
|
16
|
-
|
|
17
|
-
1. 南向:daemon 通过 `codex app-server --listen stdio://` 控制 Codex
|
|
18
|
-
2. 北向:daemon 自己暴露 `ACP` 兼容 agent,供外部 ACP client 使用
|
|
19
|
-
|
|
20
|
-
这能保证现在就能正确接入 Codex,同时不把上层协议绑死在 Codex 私有接口上。
|
|
21
|
-
|
|
22
|
-
## 运行
|
|
23
|
-
|
|
24
|
-
要求:
|
|
25
|
-
|
|
26
|
-
- Node.js 22+
|
|
27
|
-
- 本机已安装并可运行 `codex`
|
|
28
|
-
- `codex` 已完成登录
|
|
29
|
-
|
|
30
|
-
1. 启动 Web 管理端(API + 页面):
|
|
31
|
-
|
|
32
|
-
```bash
|
|
33
|
-
npm install
|
|
34
|
-
npm --prefix web install
|
|
35
|
-
npm --prefix web run build
|
|
36
|
-
npm run web:server
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
默认监听 `http://localhost:3310`
|
|
40
|
-
默认读取 `web/.env`
|
|
41
|
-
|
|
42
|
-
2. 在被控机器启动 daemon,并主动连接管理端:
|
|
43
|
-
|
|
44
|
-
```bash
|
|
45
|
-
npm run daemon
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
daemon 不再暴露本地 HTTP API,浏览器也不应直接连接 daemon。
|
|
49
|
-
默认读取根目录 `.env`
|
|
50
|
-
|
|
51
|
-
可选环境变量:
|
|
52
|
-
|
|
53
|
-
- `DAEMON_ID`:固定 daemon 标识
|
|
54
|
-
- `DAEMON_NAME`:展示名称
|
|
55
|
-
|
|
56
|
-
3. 浏览器访问管理端页面:
|
|
57
|
-
|
|
58
|
-
```bash
|
|
59
|
-
http://your-control-host:3310
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
开发模式(前端热更新):
|
|
63
|
-
|
|
64
|
-
```bash
|
|
65
|
-
npm run web:dev
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
启动 ACP bridge:
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
npm run acp
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
## 使用示例
|
|
75
|
-
|
|
76
|
-
### 示例 1:本机快速跑通
|
|
77
|
-
|
|
78
|
-
先启动控制端:
|
|
79
|
-
|
|
80
|
-
```bash
|
|
81
|
-
git clone https://github.com/gaoyu06/OhMyVibe.git
|
|
82
|
-
cd OhMyVibe
|
|
83
|
-
npm install
|
|
84
|
-
npm --prefix web install
|
|
85
|
-
npm --prefix web run build
|
|
86
|
-
npm run web:server
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
再在另一台机器或另一个终端启动 daemon:
|
|
90
|
-
|
|
91
|
-
```bash
|
|
92
|
-
cp .env.example .env
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
`.env`:
|
|
96
|
-
|
|
97
|
-
```env
|
|
98
|
-
MANAGEMENT_SERVER_URL=http://localhost:3310
|
|
99
|
-
DAEMON_NAME=ohmyvibe-local
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
启动:
|
|
103
|
-
|
|
104
|
-
```bash
|
|
105
|
-
npm run daemon
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
浏览器访问:
|
|
109
|
-
|
|
110
|
-
```text
|
|
111
|
-
http://localhost:3310
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
### 示例 2:通过 npm 全局安装 daemon
|
|
115
|
-
|
|
116
|
-
如果你只想安装被控端 daemon,可以直接安装 npm 包:
|
|
117
|
-
|
|
118
|
-
```bash
|
|
119
|
-
npm install -g ohmyvibe
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
然后直接连接到你的控制端:
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
|
-
ohmyvibe --management-server-url http://your-control-host:3310
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
也可以显式指定 daemon 名称或 id:
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
ohmyvibe daemon ^
|
|
132
|
-
--management-server-url http://your-control-host:3310 ^
|
|
133
|
-
--daemon-name office-win ^
|
|
134
|
-
--daemon-id office-win-01
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
说明:
|
|
138
|
-
|
|
139
|
-
- 当前 npm 包主要提供 `daemon` / `acp` CLI
|
|
140
|
-
- Web 控制服务端目前仍建议直接从仓库部署
|
|
141
|
-
|
|
142
|
-
## 服务端部署示例
|
|
143
|
-
|
|
144
|
-
### 示例 1:在 Linux 服务器部署控制端
|
|
145
|
-
|
|
146
|
-
```bash
|
|
147
|
-
git clone https://github.com/gaoyu06/OhMyVibe.git
|
|
148
|
-
cd OhMyVibe
|
|
149
|
-
npm install
|
|
150
|
-
npm --prefix web install
|
|
151
|
-
cp web/.env.example web/.env
|
|
152
|
-
npm --prefix web run build
|
|
153
|
-
npm run web:server
|
|
154
|
-
```
|
|
155
|
-
|
|
156
|
-
`web/.env`:
|
|
157
|
-
|
|
158
|
-
```env
|
|
159
|
-
PORT=3310
|
|
160
|
-
VITE_CONTROL_SERVER_URL=https://your-domain.example.com
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
反向代理到 `3310` 端口后,浏览器即可访问控制台,远端 daemon 使用:
|
|
164
|
-
|
|
165
|
-
```env
|
|
166
|
-
MANAGEMENT_SERVER_URL=https://your-domain.example.com
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### 示例 2:用 PM2 托管控制端
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
pm2 start "npm run web:server" --name ohmyvibe-control
|
|
173
|
-
pm2 save
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
### 示例 3:发布前验证 npm 包
|
|
177
|
-
|
|
178
|
-
```bash
|
|
179
|
-
npm run build:daemon
|
|
180
|
-
npm run pack:dry-run
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
## 全局安装 daemon
|
|
184
|
-
|
|
185
|
-
如果你要把 daemon 作为全局命令安装,当前包已经支持:
|
|
186
|
-
|
|
187
|
-
```bash
|
|
188
|
-
npm install -g ohmyvibe
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
然后直接启动:
|
|
192
|
-
|
|
193
|
-
```bash
|
|
194
|
-
ohmyvibe --management-server-url http://localhost:3310
|
|
195
|
-
```
|
|
196
|
-
|
|
197
|
-
也可以显式指定 daemon 名称或 id:
|
|
198
|
-
|
|
199
|
-
```bash
|
|
200
|
-
ohmyvibe daemon \
|
|
201
|
-
--management-server-url http://localhost:3310 \
|
|
202
|
-
--daemon-name ohmyvibe-local \
|
|
203
|
-
--daemon-id local-1
|
|
204
|
-
```
|
|
205
|
-
|
|
206
|
-
如果仍然想走环境变量,也支持:
|
|
207
|
-
|
|
208
|
-
- `MANAGEMENT_SERVER_URL`
|
|
209
|
-
- `DAEMON_ID`
|
|
210
|
-
- `DAEMON_NAME`
|
|
211
|
-
|
|
212
|
-
发布前可先验证打包内容:
|
|
213
|
-
|
|
214
|
-
```bash
|
|
215
|
-
npm run build:daemon
|
|
216
|
-
npm run pack:dry-run
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
正式发布:
|
|
220
|
-
|
|
221
|
-
```bash
|
|
222
|
-
npm publish --access public
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
## 现在支持的能力
|
|
226
|
-
|
|
227
|
-
- 创建多个独立 Codex 会话
|
|
228
|
-
- daemon 重启后恢复应用内 session 列表与 transcript
|
|
229
|
-
- 给指定会话发送消息
|
|
230
|
-
- 流式接收 assistant 文本增量
|
|
231
|
-
- 用 `item/*` 与 `turn/*` 事件维护实时 transcript
|
|
232
|
-
- 中断运行中的 turn
|
|
233
|
-
- 关闭会话
|
|
234
|
-
- 从 Codex 历史会话列表恢复,并继续在同一 `threadId` 上对话
|
|
235
|
-
- 使用独立前端从其他设备远程管理 daemon
|
|
236
|
-
- daemon 主动连接管理端,浏览器不需要直连 daemon
|
|
237
|
-
|
|
238
|
-
## 后续建议
|
|
239
|
-
|
|
240
|
-
- 把 `turn/interrupt`、审批、文件 diff、命令执行输出做成更细粒度 UI
|
|
241
|
-
- 将 ACP session 和 web session 统一到同一后端存储
|
|
242
|
-
- 为 `codex app-server` 请求/通知补完整类型约束
|
|
243
|
-
|
|
244
|
-
## 参考文档
|
|
245
|
-
|
|
246
|
-
- OpenAI Codex App Server: https://developers.openai.com/codex/app-server
|
|
247
|
-
- OpenAI Codex CLI repo: https://github.com/openai/codex
|
|
248
|
-
- ACP 协议主页: https://agentclientprotocol.com
|
|
249
|
-
- ACP TypeScript SDK: https://www.npmjs.com/package/@agentclientprotocol/sdk
|
|
1
|
+
# OhMyVibe
|
|
2
|
+
|
|
3
|
+
这是一个 `VibeCoding` 控制台:
|
|
4
|
+
|
|
5
|
+
- `daemon -> control server <- browser` 架构(daemon 主动连接管理端)
|
|
6
|
+
- 每个会话独立启动一个 `codex app-server` 子进程
|
|
7
|
+
- daemon 统一管理多会话、消息发送、中断、状态同步
|
|
8
|
+
- 额外提供一个标准 `ACP` bridge 入口,方便后续给编辑器或其他 ACP client 接入
|
|
9
|
+
- 应用侧 session 本地持久化到 `data/sessions.json`
|
|
10
|
+
- 支持从 Codex 历史 `~/.codex/sessions` 恢复会话,并绑定到原始 Codex thread
|
|
11
|
+
- Web 控制台为独立 React + shadcn 风格项目,浏览器只连接管理端
|
|
12
|
+
|
|
13
|
+
## 为什么这样做
|
|
14
|
+
|
|
15
|
+
当前官方能力里,`Codex CLI` 暴露的是 `app-server` 自动化接口,而不是原生 `ACP agent`。因此这个 MVP 采用两层桥接:
|
|
16
|
+
|
|
17
|
+
1. 南向:daemon 通过 `codex app-server --listen stdio://` 控制 Codex
|
|
18
|
+
2. 北向:daemon 自己暴露 `ACP` 兼容 agent,供外部 ACP client 使用
|
|
19
|
+
|
|
20
|
+
这能保证现在就能正确接入 Codex,同时不把上层协议绑死在 Codex 私有接口上。
|
|
21
|
+
|
|
22
|
+
## 运行
|
|
23
|
+
|
|
24
|
+
要求:
|
|
25
|
+
|
|
26
|
+
- Node.js 22+
|
|
27
|
+
- 本机已安装并可运行 `codex`
|
|
28
|
+
- `codex` 已完成登录
|
|
29
|
+
|
|
30
|
+
1. 启动 Web 管理端(API + 页面):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install
|
|
34
|
+
npm --prefix web install
|
|
35
|
+
npm --prefix web run build
|
|
36
|
+
npm run web:server
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
默认监听 `http://localhost:3310`
|
|
40
|
+
默认读取 `web/.env`
|
|
41
|
+
|
|
42
|
+
2. 在被控机器启动 daemon,并主动连接管理端:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm run daemon
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
daemon 不再暴露本地 HTTP API,浏览器也不应直接连接 daemon。
|
|
49
|
+
默认读取根目录 `.env`
|
|
50
|
+
|
|
51
|
+
可选环境变量:
|
|
52
|
+
|
|
53
|
+
- `DAEMON_ID`:固定 daemon 标识
|
|
54
|
+
- `DAEMON_NAME`:展示名称
|
|
55
|
+
|
|
56
|
+
3. 浏览器访问管理端页面:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
http://your-control-host:3310
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
开发模式(前端热更新):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm run web:dev
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
启动 ACP bridge:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm run acp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## 使用示例
|
|
75
|
+
|
|
76
|
+
### 示例 1:本机快速跑通
|
|
77
|
+
|
|
78
|
+
先启动控制端:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
git clone https://github.com/gaoyu06/OhMyVibe.git
|
|
82
|
+
cd OhMyVibe
|
|
83
|
+
npm install
|
|
84
|
+
npm --prefix web install
|
|
85
|
+
npm --prefix web run build
|
|
86
|
+
npm run web:server
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
再在另一台机器或另一个终端启动 daemon:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cp .env.example .env
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
`.env`:
|
|
96
|
+
|
|
97
|
+
```env
|
|
98
|
+
MANAGEMENT_SERVER_URL=http://localhost:3310
|
|
99
|
+
DAEMON_NAME=ohmyvibe-local
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
启动:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm run daemon
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
浏览器访问:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
http://localhost:3310
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 示例 2:通过 npm 全局安装 daemon
|
|
115
|
+
|
|
116
|
+
如果你只想安装被控端 daemon,可以直接安装 npm 包:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm install -g ohmyvibe
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
然后直接连接到你的控制端:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
ohmyvibe --management-server-url http://your-control-host:3310
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
也可以显式指定 daemon 名称或 id:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
ohmyvibe daemon ^
|
|
132
|
+
--management-server-url http://your-control-host:3310 ^
|
|
133
|
+
--daemon-name office-win ^
|
|
134
|
+
--daemon-id office-win-01
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
说明:
|
|
138
|
+
|
|
139
|
+
- 当前 npm 包主要提供 `daemon` / `acp` CLI
|
|
140
|
+
- Web 控制服务端目前仍建议直接从仓库部署
|
|
141
|
+
|
|
142
|
+
## 服务端部署示例
|
|
143
|
+
|
|
144
|
+
### 示例 1:在 Linux 服务器部署控制端
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
git clone https://github.com/gaoyu06/OhMyVibe.git
|
|
148
|
+
cd OhMyVibe
|
|
149
|
+
npm install
|
|
150
|
+
npm --prefix web install
|
|
151
|
+
cp web/.env.example web/.env
|
|
152
|
+
npm --prefix web run build
|
|
153
|
+
npm run web:server
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`web/.env`:
|
|
157
|
+
|
|
158
|
+
```env
|
|
159
|
+
PORT=3310
|
|
160
|
+
VITE_CONTROL_SERVER_URL=https://your-domain.example.com
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
反向代理到 `3310` 端口后,浏览器即可访问控制台,远端 daemon 使用:
|
|
164
|
+
|
|
165
|
+
```env
|
|
166
|
+
MANAGEMENT_SERVER_URL=https://your-domain.example.com
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### 示例 2:用 PM2 托管控制端
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pm2 start "npm run web:server" --name ohmyvibe-control
|
|
173
|
+
pm2 save
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### 示例 3:发布前验证 npm 包
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm run build:daemon
|
|
180
|
+
npm run pack:dry-run
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## 全局安装 daemon
|
|
184
|
+
|
|
185
|
+
如果你要把 daemon 作为全局命令安装,当前包已经支持:
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npm install -g ohmyvibe
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
然后直接启动:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
ohmyvibe --management-server-url http://localhost:3310
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
也可以显式指定 daemon 名称或 id:
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
ohmyvibe daemon \
|
|
201
|
+
--management-server-url http://localhost:3310 \
|
|
202
|
+
--daemon-name ohmyvibe-local \
|
|
203
|
+
--daemon-id local-1
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
如果仍然想走环境变量,也支持:
|
|
207
|
+
|
|
208
|
+
- `MANAGEMENT_SERVER_URL`
|
|
209
|
+
- `DAEMON_ID`
|
|
210
|
+
- `DAEMON_NAME`
|
|
211
|
+
|
|
212
|
+
发布前可先验证打包内容:
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
npm run build:daemon
|
|
216
|
+
npm run pack:dry-run
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
正式发布:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
npm publish --access public
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
## 现在支持的能力
|
|
226
|
+
|
|
227
|
+
- 创建多个独立 Codex 会话
|
|
228
|
+
- daemon 重启后恢复应用内 session 列表与 transcript
|
|
229
|
+
- 给指定会话发送消息
|
|
230
|
+
- 流式接收 assistant 文本增量
|
|
231
|
+
- 用 `item/*` 与 `turn/*` 事件维护实时 transcript
|
|
232
|
+
- 中断运行中的 turn
|
|
233
|
+
- 关闭会话
|
|
234
|
+
- 从 Codex 历史会话列表恢复,并继续在同一 `threadId` 上对话
|
|
235
|
+
- 使用独立前端从其他设备远程管理 daemon
|
|
236
|
+
- daemon 主动连接管理端,浏览器不需要直连 daemon
|
|
237
|
+
|
|
238
|
+
## 后续建议
|
|
239
|
+
|
|
240
|
+
- 把 `turn/interrupt`、审批、文件 diff、命令执行输出做成更细粒度 UI
|
|
241
|
+
- 将 ACP session 和 web session 统一到同一后端存储
|
|
242
|
+
- 为 `codex app-server` 请求/通知补完整类型约束
|
|
243
|
+
|
|
244
|
+
## 参考文档
|
|
245
|
+
|
|
246
|
+
- OpenAI Codex App Server: https://developers.openai.com/codex/app-server
|
|
247
|
+
- OpenAI Codex CLI repo: https://github.com/openai/codex
|
|
248
|
+
- ACP 协议主页: https://agentclientprotocol.com
|
|
249
|
+
- ACP TypeScript SDK: https://www.npmjs.com/package/@agentclientprotocol/sdk
|
package/dist/cli.js
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "dotenv/config";
|
|
3
3
|
function printHelp() {
|
|
4
|
-
console.log(`OhMyVibe CLI
|
|
5
|
-
|
|
6
|
-
Usage:
|
|
7
|
-
ohmyvibe [command] [options]
|
|
8
|
-
|
|
9
|
-
Commands:
|
|
10
|
-
daemon Start the managed daemon (default)
|
|
11
|
-
acp Start the ACP bridge
|
|
12
|
-
|
|
13
|
-
Options:
|
|
14
|
-
-u, --management-server-url <url> Control server URL
|
|
15
|
-
--daemon-id <id> Override daemon id
|
|
16
|
-
-n, --daemon-name <name> Override daemon display name
|
|
17
|
-
-h, --help Show help
|
|
18
|
-
-v, --version Show version
|
|
19
|
-
|
|
20
|
-
Examples:
|
|
21
|
-
ohmyvibe --management-server-url http://localhost:3310
|
|
22
|
-
ohmyvibe daemon -u http://localhost:3310 -n my-daemon
|
|
23
|
-
ohmyvibe acp
|
|
4
|
+
console.log(`OhMyVibe CLI
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
ohmyvibe [command] [options]
|
|
8
|
+
|
|
9
|
+
Commands:
|
|
10
|
+
daemon Start the managed daemon (default)
|
|
11
|
+
acp Start the ACP bridge
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
-u, --management-server-url <url> Control server URL
|
|
15
|
+
--daemon-id <id> Override daemon id
|
|
16
|
+
-n, --daemon-name <name> Override daemon display name
|
|
17
|
+
-h, --help Show help
|
|
18
|
+
-v, --version Show version
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
ohmyvibe --management-server-url http://localhost:3310
|
|
22
|
+
ohmyvibe daemon -u http://localhost:3310 -n my-daemon
|
|
23
|
+
ohmyvibe acp
|
|
24
24
|
`);
|
|
25
25
|
}
|
|
26
26
|
function printVersion() {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { WebSocket } from "ws";
|
|
4
|
+
import packageJson from "../../package.json" with { type: "json" };
|
|
4
5
|
export class ManagementBridge {
|
|
5
6
|
serverUrl;
|
|
6
7
|
daemonId;
|
|
@@ -87,6 +88,7 @@ export class ManagementBridge {
|
|
|
87
88
|
daemon: {
|
|
88
89
|
id: this.daemonId,
|
|
89
90
|
name: this.daemonName,
|
|
91
|
+
version: packageJson.version,
|
|
90
92
|
platform: process.platform,
|
|
91
93
|
cwd: process.cwd(),
|
|
92
94
|
connectedAt: new Date().toISOString(),
|
|
@@ -12,7 +12,9 @@ export class SessionManager extends EventEmitter {
|
|
|
12
12
|
store = new SessionStore();
|
|
13
13
|
configCache;
|
|
14
14
|
persistTimer;
|
|
15
|
-
|
|
15
|
+
persistInFlight = false;
|
|
16
|
+
dirtySessionIds = new Set();
|
|
17
|
+
deletedSessionIds = new Set();
|
|
16
18
|
sessionUpdateTimer;
|
|
17
19
|
pendingSessionUpdates = new Map();
|
|
18
20
|
constructor() {
|
|
@@ -193,12 +195,13 @@ export class SessionManager extends EventEmitter {
|
|
|
193
195
|
sandbox: input.sandbox ?? "workspace-write",
|
|
194
196
|
approvalPolicy: input.approvalPolicy ?? "never",
|
|
195
197
|
transcript: [],
|
|
198
|
+
previewEntries: [],
|
|
196
199
|
liveMessages: new Map(),
|
|
197
200
|
liveReasoning: new Map(),
|
|
198
201
|
pendingApprovals: new Map(),
|
|
199
202
|
};
|
|
200
203
|
this.sessions.set(sessionId, session);
|
|
201
|
-
this.persist();
|
|
204
|
+
this.persist(session);
|
|
202
205
|
this.emitChange({ type: "session-created", session: this.toSummary(session) });
|
|
203
206
|
const runtime = this.createRuntime(session);
|
|
204
207
|
session.startupPromise = this.trackSessionStartup(session, this.startSessionInBackground(session, runtime, input));
|
|
@@ -226,12 +229,13 @@ export class SessionManager extends EventEmitter {
|
|
|
226
229
|
approvalPolicy: input.approvalPolicy ?? "never",
|
|
227
230
|
codexThreadId: input.threadId,
|
|
228
231
|
transcript: [],
|
|
232
|
+
previewEntries: [],
|
|
229
233
|
liveMessages: new Map(),
|
|
230
234
|
liveReasoning: new Map(),
|
|
231
235
|
pendingApprovals: new Map(),
|
|
232
236
|
};
|
|
233
237
|
this.sessions.set(session.id, session);
|
|
234
|
-
this.persist();
|
|
238
|
+
this.persist(session);
|
|
235
239
|
this.emitChange({ type: "session-created", session: this.toSummary(session) });
|
|
236
240
|
const runtime = this.createRuntime(session);
|
|
237
241
|
session.startupPromise = this.trackSessionStartup(session, this.restoreSessionInBackground(session, runtime, input));
|
|
@@ -250,7 +254,7 @@ export class SessionManager extends EventEmitter {
|
|
|
250
254
|
session.approvalPolicy = approvalPolicy;
|
|
251
255
|
session.configDirty = session.configDirty || runtimeChanged;
|
|
252
256
|
this.touch(session);
|
|
253
|
-
this.persist();
|
|
257
|
+
this.persist(session);
|
|
254
258
|
this.emitChange({ type: "session-updated", session: this.toSummary(session) });
|
|
255
259
|
if (session.status !== "running" && session.status !== "starting") {
|
|
256
260
|
await this.runtimeFor(session).applyPendingConfig();
|
|
@@ -265,7 +269,7 @@ export class SessionManager extends EventEmitter {
|
|
|
265
269
|
}
|
|
266
270
|
session.title = nextTitle;
|
|
267
271
|
this.touch(session);
|
|
268
|
-
this.persist();
|
|
272
|
+
this.persist(session);
|
|
269
273
|
this.emitChange({ type: "session-updated", session: this.toSummary(session) });
|
|
270
274
|
return this.getOrThrow(sessionId);
|
|
271
275
|
}
|
|
@@ -287,7 +291,7 @@ export class SessionManager extends EventEmitter {
|
|
|
287
291
|
await this.runtimeFor(session).close();
|
|
288
292
|
this.sessions.delete(sessionId);
|
|
289
293
|
this.runtimes.delete(sessionId);
|
|
290
|
-
this.
|
|
294
|
+
this.persistDeletion(sessionId);
|
|
291
295
|
this.emitChange({ type: "session-deleted", sessionId });
|
|
292
296
|
}
|
|
293
297
|
restorePersistedSessions() {
|
|
@@ -309,6 +313,8 @@ export class SessionManager extends EventEmitter {
|
|
|
309
313
|
codexSource: persisted.codexSource,
|
|
310
314
|
lastError: persisted.lastError,
|
|
311
315
|
transcript: Array.isArray(persisted.transcript) ? persisted.transcript : [],
|
|
316
|
+
previewEntries: Array.isArray(persisted.previewEntries) ? persisted.previewEntries : [],
|
|
317
|
+
previewDirty: !Array.isArray(persisted.previewEntries),
|
|
312
318
|
liveMessages: new Map(),
|
|
313
319
|
liveReasoning: new Map(),
|
|
314
320
|
pendingApprovals: new Map(),
|
|
@@ -317,26 +323,78 @@ export class SessionManager extends EventEmitter {
|
|
|
317
323
|
this.createRuntime(session);
|
|
318
324
|
}
|
|
319
325
|
}
|
|
320
|
-
persist() {
|
|
321
|
-
|
|
326
|
+
persist(target) {
|
|
327
|
+
if (typeof target === "string" && target) {
|
|
328
|
+
this.deletedSessionIds.delete(target);
|
|
329
|
+
this.dirtySessionIds.add(target);
|
|
330
|
+
}
|
|
331
|
+
else if (target && typeof target !== "string") {
|
|
332
|
+
this.deletedSessionIds.delete(target.id);
|
|
333
|
+
this.dirtySessionIds.add(target.id);
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
for (const sessionId of this.sessions.keys()) {
|
|
337
|
+
this.deletedSessionIds.delete(sessionId);
|
|
338
|
+
this.dirtySessionIds.add(sessionId);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
this.schedulePersistFlush();
|
|
342
|
+
}
|
|
343
|
+
persistDeletion(sessionId) {
|
|
344
|
+
this.dirtySessionIds.delete(sessionId);
|
|
345
|
+
this.deletedSessionIds.add(sessionId);
|
|
346
|
+
this.schedulePersistFlush();
|
|
347
|
+
}
|
|
348
|
+
schedulePersistFlush() {
|
|
322
349
|
if (this.persistTimer) {
|
|
323
350
|
return;
|
|
324
351
|
}
|
|
325
|
-
// Streaming turns can emit many small deltas; batch disk writes so read-only
|
|
326
|
-
// APIs and other sessions are not blocked by synchronous full-store saves.
|
|
327
352
|
this.persistTimer = setTimeout(() => {
|
|
328
353
|
this.persistTimer = undefined;
|
|
329
|
-
|
|
330
|
-
|
|
354
|
+
void this.flushPersist();
|
|
355
|
+
}, 120);
|
|
356
|
+
}
|
|
357
|
+
async flushPersist() {
|
|
358
|
+
if (this.persistInFlight) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (!this.dirtySessionIds.size && !this.deletedSessionIds.size) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
this.persistInFlight = true;
|
|
365
|
+
const deletedSessionIds = Array.from(this.deletedSessionIds);
|
|
366
|
+
const dirtySessionIds = Array.from(this.dirtySessionIds).filter((sessionId) => !this.deletedSessionIds.has(sessionId));
|
|
367
|
+
this.deletedSessionIds.clear();
|
|
368
|
+
this.dirtySessionIds.clear();
|
|
369
|
+
try {
|
|
370
|
+
for (const sessionId of dirtySessionIds) {
|
|
371
|
+
const session = this.sessions.get(sessionId);
|
|
372
|
+
if (!session) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
await this.store.saveSession(this.serializeSessionForStore(session));
|
|
331
376
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
.map((session) => this.get(session.id))
|
|
335
|
-
.filter((session) => Boolean(session)));
|
|
336
|
-
if (this.persistQueued) {
|
|
337
|
-
this.persist();
|
|
377
|
+
for (const sessionId of deletedSessionIds) {
|
|
378
|
+
await this.store.deleteSession(sessionId);
|
|
338
379
|
}
|
|
339
|
-
}
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
for (const sessionId of dirtySessionIds) {
|
|
383
|
+
if (!this.deletedSessionIds.has(sessionId)) {
|
|
384
|
+
this.dirtySessionIds.add(sessionId);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
for (const sessionId of deletedSessionIds) {
|
|
388
|
+
this.deletedSessionIds.add(sessionId);
|
|
389
|
+
this.dirtySessionIds.delete(sessionId);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
finally {
|
|
393
|
+
this.persistInFlight = false;
|
|
394
|
+
if (this.dirtySessionIds.size || this.deletedSessionIds.size) {
|
|
395
|
+
this.schedulePersistFlush();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
340
398
|
}
|
|
341
399
|
async startSessionInBackground(session, runtime, input) {
|
|
342
400
|
try {
|
|
@@ -351,7 +409,7 @@ export class SessionManager extends EventEmitter {
|
|
|
351
409
|
text: `Session startup failed: ${session.lastError}`,
|
|
352
410
|
status: "failed",
|
|
353
411
|
});
|
|
354
|
-
this.persist();
|
|
412
|
+
this.persist(session);
|
|
355
413
|
this.emitChange({ type: "session-updated", session: this.toSummary(session) });
|
|
356
414
|
}
|
|
357
415
|
}
|
|
@@ -368,7 +426,7 @@ export class SessionManager extends EventEmitter {
|
|
|
368
426
|
text: `Session restore failed: ${session.lastError}`,
|
|
369
427
|
status: "failed",
|
|
370
428
|
});
|
|
371
|
-
this.persist();
|
|
429
|
+
this.persist(session);
|
|
372
430
|
this.emitChange({ type: "session-updated", session: this.toSummary(session) });
|
|
373
431
|
}
|
|
374
432
|
}
|
|
@@ -379,8 +437,9 @@ export class SessionManager extends EventEmitter {
|
|
|
379
437
|
...input,
|
|
380
438
|
};
|
|
381
439
|
session.transcript.push(entry);
|
|
440
|
+
this.markPreviewDirty(session);
|
|
382
441
|
this.touch(session);
|
|
383
|
-
this.persist();
|
|
442
|
+
this.persist(session);
|
|
384
443
|
this.emitChange({ type: "session-entry", sessionId: session.id, entry });
|
|
385
444
|
this.emitChange({ type: "session-updated", session: this.toSummary(session) });
|
|
386
445
|
return entry;
|
|
@@ -398,9 +457,18 @@ export class SessionManager extends EventEmitter {
|
|
|
398
457
|
}
|
|
399
458
|
else {
|
|
400
459
|
const existing = session.transcript[existingIndex];
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
460
|
+
const keepExistingReadPreview = existing.kind === "tool" &&
|
|
461
|
+
entry.kind === "tool" &&
|
|
462
|
+
this.isReadToolName(existing.meta?.name);
|
|
463
|
+
const mergedText = keepExistingReadPreview
|
|
464
|
+
? existing.text
|
|
465
|
+
: existing.kind === "tool" &&
|
|
466
|
+
entry.kind === "tool" &&
|
|
467
|
+
existing.text &&
|
|
468
|
+
entry.text &&
|
|
469
|
+
existing.text !== entry.text
|
|
470
|
+
? `${existing.text}\n\n${entry.text}`.trim()
|
|
471
|
+
: entry.text || existing.text;
|
|
404
472
|
session.transcript[existingIndex] = {
|
|
405
473
|
...existing,
|
|
406
474
|
...entry,
|
|
@@ -412,14 +480,18 @@ export class SessionManager extends EventEmitter {
|
|
|
412
480
|
entry: session.transcript[existingIndex],
|
|
413
481
|
};
|
|
414
482
|
}
|
|
483
|
+
this.markPreviewDirty(session);
|
|
415
484
|
this.touch(session);
|
|
416
|
-
this.persist();
|
|
485
|
+
this.persist(session);
|
|
417
486
|
this.emitChange(event);
|
|
418
487
|
this.emitChange({ type: "session-updated", session: this.toSummary(session) });
|
|
419
488
|
}
|
|
420
489
|
touch(session) {
|
|
421
490
|
session.updatedAt = new Date().toISOString();
|
|
422
491
|
}
|
|
492
|
+
markPreviewDirty(session) {
|
|
493
|
+
session.previewDirty = true;
|
|
494
|
+
}
|
|
423
495
|
emitChange(event) {
|
|
424
496
|
if (event.type === "session-updated") {
|
|
425
497
|
this.pendingSessionUpdates.set(event.session.id, event.session);
|
|
@@ -451,8 +523,9 @@ export class SessionManager extends EventEmitter {
|
|
|
451
523
|
}
|
|
452
524
|
createRuntime(session) {
|
|
453
525
|
const runtime = new SessionRuntime(session, {
|
|
454
|
-
persist: () => this.persist(),
|
|
526
|
+
persist: () => this.persist(session),
|
|
455
527
|
emitChange: (event) => this.emitChange(event),
|
|
528
|
+
markPreviewDirty: (target) => this.markPreviewDirty(target),
|
|
456
529
|
touch: (target) => this.touch(target),
|
|
457
530
|
toSummary: (target) => this.toSummary(target),
|
|
458
531
|
getDetails: (sessionId) => this.getOrThrow(sessionId),
|
|
@@ -467,7 +540,31 @@ export class SessionManager extends EventEmitter {
|
|
|
467
540
|
runtimeFor(session) {
|
|
468
541
|
return this.runtimes.get(session.id) ?? this.createRuntime(session);
|
|
469
542
|
}
|
|
543
|
+
serializeSessionForStore(session) {
|
|
544
|
+
const previewEntries = this.getPreviewEntries(session);
|
|
545
|
+
return {
|
|
546
|
+
id: session.id,
|
|
547
|
+
title: session.title,
|
|
548
|
+
cwd: session.cwd,
|
|
549
|
+
createdAt: session.createdAt,
|
|
550
|
+
updatedAt: session.updatedAt,
|
|
551
|
+
status: session.status,
|
|
552
|
+
origin: session.origin,
|
|
553
|
+
model: session.model,
|
|
554
|
+
reasoningEffort: session.reasoningEffort,
|
|
555
|
+
sandbox: session.sandbox,
|
|
556
|
+
approvalPolicy: session.approvalPolicy,
|
|
557
|
+
codexThreadId: session.codexThreadId,
|
|
558
|
+
codexPath: session.codexPath,
|
|
559
|
+
codexSource: session.codexSource,
|
|
560
|
+
lastError: session.lastError,
|
|
561
|
+
transcriptCount: session.transcript.length,
|
|
562
|
+
previewEntries,
|
|
563
|
+
transcript: [...session.transcript],
|
|
564
|
+
};
|
|
565
|
+
}
|
|
470
566
|
toSummary(session) {
|
|
567
|
+
const previewEntries = this.getPreviewEntries(session);
|
|
471
568
|
return {
|
|
472
569
|
id: session.id,
|
|
473
570
|
title: session.title,
|
|
@@ -485,8 +582,102 @@ export class SessionManager extends EventEmitter {
|
|
|
485
582
|
codexSource: session.codexSource,
|
|
486
583
|
lastError: session.lastError,
|
|
487
584
|
transcriptCount: session.transcript.length,
|
|
585
|
+
previewEntries,
|
|
488
586
|
};
|
|
489
587
|
}
|
|
588
|
+
getPreviewEntries(session) {
|
|
589
|
+
if (!session.previewDirty) {
|
|
590
|
+
return session.previewEntries;
|
|
591
|
+
}
|
|
592
|
+
session.previewEntries = this.toPreviewEntries(session.transcript);
|
|
593
|
+
session.previewDirty = false;
|
|
594
|
+
return session.previewEntries;
|
|
595
|
+
}
|
|
596
|
+
toPreviewEntries(transcript) {
|
|
597
|
+
if (!transcript.length) {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
const selected = [];
|
|
601
|
+
let textBudget = 520;
|
|
602
|
+
for (let index = transcript.length - 1; index >= 0; index -= 1) {
|
|
603
|
+
const entry = transcript[index];
|
|
604
|
+
if (!entry) {
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const previewText = this.toPreviewText(entry);
|
|
608
|
+
if (!previewText) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const cost = Math.max(40, previewText.length);
|
|
612
|
+
if (selected.length && textBudget - cost < 0) {
|
|
613
|
+
break;
|
|
614
|
+
}
|
|
615
|
+
selected.unshift({
|
|
616
|
+
id: entry.id,
|
|
617
|
+
kind: entry.kind,
|
|
618
|
+
previewText,
|
|
619
|
+
createdAt: entry.createdAt,
|
|
620
|
+
status: entry.status,
|
|
621
|
+
});
|
|
622
|
+
textBudget -= cost;
|
|
623
|
+
if (selected.length >= 6) {
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return selected;
|
|
628
|
+
}
|
|
629
|
+
toPreviewText(entry) {
|
|
630
|
+
if (entry.kind === "assistant" && entry.status === "streaming" && !entry.text.trim()) {
|
|
631
|
+
return "Thinking...";
|
|
632
|
+
}
|
|
633
|
+
if (entry.kind === "approval") {
|
|
634
|
+
const approvalKind = typeof entry.meta?.approvalKind === "string" ? entry.meta.approvalKind : "";
|
|
635
|
+
if (approvalKind) {
|
|
636
|
+
return approvalKind;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (entry.kind === "tool" || entry.kind === "command" || entry.kind === "file_change") {
|
|
640
|
+
return this.lastLines(entry.text, 6) || this.entryLabel(entry);
|
|
641
|
+
}
|
|
642
|
+
const collapsed = String(entry.text || "")
|
|
643
|
+
.replace(/\s+/g, " ")
|
|
644
|
+
.trim();
|
|
645
|
+
return collapsed || this.entryLabel(entry);
|
|
646
|
+
}
|
|
647
|
+
entryLabel(entry) {
|
|
648
|
+
switch (entry.kind) {
|
|
649
|
+
case "user":
|
|
650
|
+
return "User";
|
|
651
|
+
case "assistant":
|
|
652
|
+
return entry.status === "streaming" ? "Assistant" : "Reply";
|
|
653
|
+
case "reasoning":
|
|
654
|
+
return "Thinking";
|
|
655
|
+
case "tool":
|
|
656
|
+
return "Tool";
|
|
657
|
+
case "command":
|
|
658
|
+
return "Command";
|
|
659
|
+
case "file_change":
|
|
660
|
+
return "Diff";
|
|
661
|
+
case "approval":
|
|
662
|
+
return "Approval";
|
|
663
|
+
default:
|
|
664
|
+
return "System";
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
lastLines(text, limit) {
|
|
668
|
+
const lines = String(text || "")
|
|
669
|
+
.split(/\r?\n/)
|
|
670
|
+
.map((line) => line.trimEnd())
|
|
671
|
+
.filter((line) => line.trim().length > 0);
|
|
672
|
+
return lines.slice(-limit).join("\n").trim();
|
|
673
|
+
}
|
|
674
|
+
isReadToolName(value) {
|
|
675
|
+
if (typeof value !== "string") {
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
const normalized = value.replace(/[\s-]+/g, "_").trim().toLowerCase();
|
|
679
|
+
return normalized === "read" || normalized === "read_file" || normalized === "readfile";
|
|
680
|
+
}
|
|
490
681
|
getSessionOrThrow(sessionId) {
|
|
491
682
|
const session = this.sessions.get(sessionId);
|
|
492
683
|
if (!session) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { CodexAppServerClient } from "./codexAppServerClient.js";
|
|
2
3
|
export class SessionRuntime {
|
|
3
4
|
session;
|
|
@@ -56,6 +57,7 @@ export class SessionRuntime {
|
|
|
56
57
|
resolvedAt: new Date().toISOString(),
|
|
57
58
|
decision,
|
|
58
59
|
};
|
|
60
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
59
61
|
}
|
|
60
62
|
this.callbacks.touch(this.session);
|
|
61
63
|
this.callbacks.persist();
|
|
@@ -158,6 +160,7 @@ export class SessionRuntime {
|
|
|
158
160
|
this.session.title = response.thread?.name || response.thread?.preview || this.session.title;
|
|
159
161
|
this.session.status = this.mapThreadStatus(response.thread?.status);
|
|
160
162
|
this.session.transcript = this.threadToTranscript(response.thread);
|
|
163
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
161
164
|
this.session.liveMessages.clear();
|
|
162
165
|
this.session.liveReasoning.clear();
|
|
163
166
|
this.session.pendingApprovals.clear();
|
|
@@ -315,6 +318,7 @@ export class SessionRuntime {
|
|
|
315
318
|
const entry = this.ensureAssistantEntry(item.id);
|
|
316
319
|
entry.status = "streaming";
|
|
317
320
|
this.trackTurnEntry(entry);
|
|
321
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
318
322
|
}
|
|
319
323
|
this.callbacks.touch(this.session);
|
|
320
324
|
this.callbacks.persist();
|
|
@@ -329,6 +333,7 @@ export class SessionRuntime {
|
|
|
329
333
|
entry.text += delta;
|
|
330
334
|
entry.status = "streaming";
|
|
331
335
|
this.markTurnOutput(entry);
|
|
336
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
332
337
|
this.callbacks.touch(this.session);
|
|
333
338
|
this.callbacks.persist();
|
|
334
339
|
this.callbacks.emitChange({
|
|
@@ -348,6 +353,7 @@ export class SessionRuntime {
|
|
|
348
353
|
entry.text += delta;
|
|
349
354
|
entry.status = "streaming";
|
|
350
355
|
this.markTurnOutput(entry);
|
|
356
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
351
357
|
this.callbacks.touch(this.session);
|
|
352
358
|
this.callbacks.persist();
|
|
353
359
|
this.callbacks.emitChange({
|
|
@@ -390,6 +396,7 @@ export class SessionRuntime {
|
|
|
390
396
|
this.session.liveMessages.clear();
|
|
391
397
|
this.session.liveReasoning.clear();
|
|
392
398
|
this.session.currentTurnMetrics = undefined;
|
|
399
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
393
400
|
this.callbacks.touch(this.session);
|
|
394
401
|
this.callbacks.persist();
|
|
395
402
|
if (updatedEntries.length || removedEntryIds.length) {
|
|
@@ -528,7 +535,7 @@ export class SessionRuntime {
|
|
|
528
535
|
return {
|
|
529
536
|
id: item.id,
|
|
530
537
|
kind: "tool",
|
|
531
|
-
text:
|
|
538
|
+
text: this.formatStructuredToolCall(item),
|
|
532
539
|
status: item.status ?? "completed",
|
|
533
540
|
createdAt,
|
|
534
541
|
};
|
|
@@ -562,8 +569,19 @@ export class SessionRuntime {
|
|
|
562
569
|
return { id: item.id, kind: "system", text: item.text ?? "", createdAt, status: "completed" };
|
|
563
570
|
case "enteredReviewMode":
|
|
564
571
|
case "exitedReviewMode":
|
|
565
|
-
case "contextCompaction":
|
|
566
572
|
return { id: item.id, kind: "system", text: JSON.stringify(item, null, 2), createdAt };
|
|
573
|
+
case "contextCompaction":
|
|
574
|
+
return {
|
|
575
|
+
id: item.id,
|
|
576
|
+
kind: "system",
|
|
577
|
+
text: "Context compacted",
|
|
578
|
+
createdAt,
|
|
579
|
+
status: "completed",
|
|
580
|
+
meta: {
|
|
581
|
+
eventType: "contextCompaction",
|
|
582
|
+
payload: item,
|
|
583
|
+
},
|
|
584
|
+
};
|
|
567
585
|
default:
|
|
568
586
|
return undefined;
|
|
569
587
|
}
|
|
@@ -714,6 +732,7 @@ export class SessionRuntime {
|
|
|
714
732
|
};
|
|
715
733
|
this.session.liveMessages.set(itemId, entry);
|
|
716
734
|
this.session.transcript.push(entry);
|
|
735
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
717
736
|
this.trackTurnEntry(entry);
|
|
718
737
|
this.callbacks.persist();
|
|
719
738
|
this.callbacks.emitChange({ type: "session-entry", sessionId: this.session.id, entry });
|
|
@@ -732,6 +751,7 @@ export class SessionRuntime {
|
|
|
732
751
|
};
|
|
733
752
|
this.session.liveReasoning.set(itemId, entry);
|
|
734
753
|
this.session.transcript.push(entry);
|
|
754
|
+
this.callbacks.markPreviewDirty(this.session);
|
|
735
755
|
this.trackTurnEntry(entry);
|
|
736
756
|
this.callbacks.persist();
|
|
737
757
|
this.callbacks.emitChange({ type: "session-entry", sessionId: this.session.id, entry });
|
|
@@ -811,17 +831,103 @@ export class SessionRuntime {
|
|
|
811
831
|
}
|
|
812
832
|
formatFunctionCall(item) {
|
|
813
833
|
const name = item?.name || "tool";
|
|
834
|
+
if (this.isReadToolName(name)) {
|
|
835
|
+
const target = this.extractReadTarget(item?.arguments);
|
|
836
|
+
return target ? `read ${target}` : "read";
|
|
837
|
+
}
|
|
814
838
|
const args = this.prettyJsonString(item?.arguments);
|
|
815
839
|
return args ? `${name}\n\n${args}` : name;
|
|
816
840
|
}
|
|
817
841
|
formatFunctionCallOutput(item) {
|
|
842
|
+
if (this.isReadToolName(item?.name)) {
|
|
843
|
+
return "";
|
|
844
|
+
}
|
|
818
845
|
return String(item?.output ?? "").trim();
|
|
819
846
|
}
|
|
820
847
|
formatCustomToolCall(item) {
|
|
821
848
|
const name = item?.name || "custom_tool";
|
|
849
|
+
if (this.isReadToolName(name)) {
|
|
850
|
+
const target = this.extractReadTarget(item?.input);
|
|
851
|
+
return target ? `read ${target}` : "read";
|
|
852
|
+
}
|
|
822
853
|
const input = typeof item?.input === "string" ? item.input : this.prettyJson(item?.input);
|
|
823
854
|
return input ? `${name}\n\n${input}` : name;
|
|
824
855
|
}
|
|
856
|
+
formatStructuredToolCall(item) {
|
|
857
|
+
const name = item?.name || item?.toolName || item?.tool?.name || item?.type || "tool";
|
|
858
|
+
if (this.isReadToolName(name)) {
|
|
859
|
+
const target = this.extractReadTarget(item?.arguments ?? item?.input ?? item?.params ?? item);
|
|
860
|
+
return target ? `read ${target}` : "read";
|
|
861
|
+
}
|
|
862
|
+
return JSON.stringify(item, null, 2);
|
|
863
|
+
}
|
|
864
|
+
isReadToolName(value) {
|
|
865
|
+
if (typeof value !== "string") {
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
const normalized = value.replace(/[\s-]+/g, "_").trim().toLowerCase();
|
|
869
|
+
return normalized === "read" || normalized === "read_file" || normalized === "readfile";
|
|
870
|
+
}
|
|
871
|
+
extractReadTarget(value) {
|
|
872
|
+
if (typeof value === "string") {
|
|
873
|
+
const trimmed = value.trim();
|
|
874
|
+
if (!trimmed) {
|
|
875
|
+
return "";
|
|
876
|
+
}
|
|
877
|
+
const parsed = this.parseJsonLike(trimmed);
|
|
878
|
+
if (!parsed) {
|
|
879
|
+
const normalized = trimmed.replace(/^file:\/\//i, "").replace(/[\\/]+$/, "");
|
|
880
|
+
return path.basename(normalized) || normalized;
|
|
881
|
+
}
|
|
882
|
+
value = parsed;
|
|
883
|
+
}
|
|
884
|
+
const parsed = this.parseJsonLike(value);
|
|
885
|
+
if (!parsed || typeof parsed !== "object") {
|
|
886
|
+
return "";
|
|
887
|
+
}
|
|
888
|
+
const candidate = this.firstStringValue(parsed, [
|
|
889
|
+
"filePath",
|
|
890
|
+
"filepath",
|
|
891
|
+
"path",
|
|
892
|
+
"filename",
|
|
893
|
+
"file",
|
|
894
|
+
"target",
|
|
895
|
+
"uri",
|
|
896
|
+
"name",
|
|
897
|
+
]);
|
|
898
|
+
if (!candidate) {
|
|
899
|
+
return "";
|
|
900
|
+
}
|
|
901
|
+
const normalized = candidate.replace(/^file:\/\//i, "").replace(/[\\/]+$/, "").trim();
|
|
902
|
+
if (!normalized) {
|
|
903
|
+
return "";
|
|
904
|
+
}
|
|
905
|
+
return path.basename(normalized) || normalized;
|
|
906
|
+
}
|
|
907
|
+
parseJsonLike(value) {
|
|
908
|
+
if (typeof value !== "string") {
|
|
909
|
+
return value;
|
|
910
|
+
}
|
|
911
|
+
const trimmed = value.trim();
|
|
912
|
+
if (!trimmed) {
|
|
913
|
+
return undefined;
|
|
914
|
+
}
|
|
915
|
+
try {
|
|
916
|
+
return JSON.parse(trimmed);
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
return undefined;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
firstStringValue(value, keys) {
|
|
923
|
+
for (const key of keys) {
|
|
924
|
+
const candidate = value[key];
|
|
925
|
+
if (typeof candidate === "string" && candidate.trim()) {
|
|
926
|
+
return candidate.trim();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return "";
|
|
930
|
+
}
|
|
825
931
|
prettyJsonString(value) {
|
|
826
932
|
if (typeof value !== "string" || !value.trim()) {
|
|
827
933
|
return "";
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import fsPromises from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
export class SessionStore {
|
|
4
5
|
legacyFilePath;
|
|
5
6
|
sessionsDirPath;
|
|
7
|
+
initializationPromise;
|
|
6
8
|
constructor(rootDir = process.cwd()) {
|
|
7
9
|
this.legacyFilePath = path.join(rootDir, "data", "sessions.json");
|
|
8
10
|
this.sessionsDirPath = path.join(rootDir, "data", "sessions");
|
|
@@ -14,30 +16,15 @@ export class SessionStore {
|
|
|
14
16
|
}
|
|
15
17
|
return this.loadLegacy();
|
|
16
18
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
for (const entry of fs.readdirSync(this.sessionsDirPath, { withFileTypes: true })) {
|
|
27
|
-
if (!entry.isFile() || !entry.name.endsWith(".json")) {
|
|
28
|
-
continue;
|
|
29
|
-
}
|
|
30
|
-
if (!expectedFileNames.has(entry.name)) {
|
|
31
|
-
fs.rmSync(path.join(this.sessionsDirPath, entry.name), { force: true });
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
catch {
|
|
36
|
-
// noop
|
|
37
|
-
}
|
|
38
|
-
if (fs.existsSync(this.legacyFilePath)) {
|
|
39
|
-
fs.rmSync(this.legacyFilePath, { force: true });
|
|
40
|
-
}
|
|
19
|
+
async saveSession(session) {
|
|
20
|
+
await this.ensureInitialized();
|
|
21
|
+
await fsPromises.writeFile(path.join(this.sessionsDirPath, this.sessionFileName(session.id)), JSON.stringify(session, null, 2), "utf8");
|
|
22
|
+
}
|
|
23
|
+
async deleteSession(sessionId) {
|
|
24
|
+
await this.ensureInitialized();
|
|
25
|
+
await fsPromises.rm(path.join(this.sessionsDirPath, this.sessionFileName(sessionId)), {
|
|
26
|
+
force: true,
|
|
27
|
+
});
|
|
41
28
|
}
|
|
42
29
|
loadFromDirectory() {
|
|
43
30
|
try {
|
|
@@ -80,6 +67,19 @@ export class SessionStore {
|
|
|
80
67
|
sessionFileName(sessionId) {
|
|
81
68
|
return `${sessionId}.json`;
|
|
82
69
|
}
|
|
70
|
+
ensureInitialized() {
|
|
71
|
+
if (!this.initializationPromise) {
|
|
72
|
+
const initialization = (async () => {
|
|
73
|
+
await fsPromises.mkdir(this.sessionsDirPath, { recursive: true });
|
|
74
|
+
await fsPromises.rm(this.legacyFilePath, { force: true }).catch(() => undefined);
|
|
75
|
+
})();
|
|
76
|
+
this.initializationPromise = initialization.catch((error) => {
|
|
77
|
+
this.initializationPromise = undefined;
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return this.initializationPromise;
|
|
82
|
+
}
|
|
83
83
|
isSessionDetails(value) {
|
|
84
84
|
if (!value || typeof value !== "object") {
|
|
85
85
|
return false;
|
package/package.json
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ohmyvibe",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "VibeCoding daemon + web console MVP for orchestrating Codex sessions",
|
|
5
|
-
"bin": {
|
|
6
|
-
"ohmyvibe": "./dist/cli.js"
|
|
7
|
-
},
|
|
8
|
-
"files": [
|
|
9
|
-
"dist",
|
|
10
|
-
"README.md",
|
|
11
|
-
".env.example"
|
|
12
|
-
],
|
|
13
|
-
"main": "dist/daemon/index.js",
|
|
14
|
-
"scripts": {
|
|
15
|
-
"build": "npm run build:daemon && npm run build:web",
|
|
16
|
-
"build:daemon": "tsc -p tsconfig.json",
|
|
17
|
-
"build:web": "npm --prefix web run build",
|
|
18
|
-
"pack:dry-run": "npm pack --dry-run",
|
|
19
|
-
"daemon": "tsx src/daemon/index.ts",
|
|
20
|
-
"acp": "tsx src/acp/index.ts",
|
|
21
|
-
"dev": "tsx watch src/daemon/index.ts",
|
|
22
|
-
"web:dev": "npm --prefix web run dev",
|
|
23
|
-
"web:preview": "npm --prefix web run preview",
|
|
24
|
-
"web:server": "npm --prefix web run server",
|
|
25
|
-
"prepublishOnly": "npm run build:daemon"
|
|
26
|
-
},
|
|
27
|
-
"keywords": [],
|
|
28
|
-
"author": "",
|
|
29
|
-
"license": "ISC",
|
|
30
|
-
"type": "module",
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=22"
|
|
33
|
-
},
|
|
34
|
-
"dependencies": {
|
|
35
|
-
"@agentclientprotocol/sdk": "^0.18.0",
|
|
36
|
-
"dotenv": "^17.4.1",
|
|
37
|
-
"express": "^5.2.1",
|
|
38
|
-
"ws": "^8.20.0"
|
|
39
|
-
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"@types/express": "^5.0.6",
|
|
42
|
-
"@types/ws": "^8.18.1",
|
|
43
|
-
"tsx": "^4.21.0",
|
|
44
|
-
"typescript": "^6.0.2"
|
|
45
|
-
}
|
|
46
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ohmyvibe",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "VibeCoding daemon + web console MVP for orchestrating Codex sessions",
|
|
5
|
+
"bin": {
|
|
6
|
+
"ohmyvibe": "./dist/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md",
|
|
11
|
+
".env.example"
|
|
12
|
+
],
|
|
13
|
+
"main": "dist/daemon/index.js",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "npm run build:daemon && npm run build:web",
|
|
16
|
+
"build:daemon": "tsc -p tsconfig.json",
|
|
17
|
+
"build:web": "npm --prefix web run build",
|
|
18
|
+
"pack:dry-run": "npm pack --dry-run",
|
|
19
|
+
"daemon": "tsx src/daemon/index.ts",
|
|
20
|
+
"acp": "tsx src/acp/index.ts",
|
|
21
|
+
"dev": "tsx watch src/daemon/index.ts",
|
|
22
|
+
"web:dev": "npm --prefix web run dev",
|
|
23
|
+
"web:preview": "npm --prefix web run preview",
|
|
24
|
+
"web:server": "npm --prefix web run server",
|
|
25
|
+
"prepublishOnly": "npm run build:daemon"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"type": "module",
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@agentclientprotocol/sdk": "^0.18.0",
|
|
36
|
+
"dotenv": "^17.4.1",
|
|
37
|
+
"express": "^5.2.1",
|
|
38
|
+
"ws": "^8.20.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/express": "^5.0.6",
|
|
42
|
+
"@types/ws": "^8.18.1",
|
|
43
|
+
"tsx": "^4.21.0",
|
|
44
|
+
"typescript": "^6.0.2"
|
|
45
|
+
}
|
|
46
|
+
}
|