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 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
- persistQueued = false;
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.persist();
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
- this.persistQueued = true;
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
- if (!this.persistQueued) {
330
- return;
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
- this.persistQueued = false;
333
- this.store.save(Array.from(this.sessions.values())
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
- }, 120);
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 mergedText = existing.kind === "tool" && entry.kind === "tool" && existing.text && entry.text && existing.text !== entry.text
402
- ? `${existing.text}\n\n${entry.text}`.trim()
403
- : entry.text || existing.text;
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: JSON.stringify(item, null, 2),
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
- save(sessions) {
18
- fs.mkdirSync(this.sessionsDirPath, { recursive: true });
19
- const expectedFileNames = new Set();
20
- for (const session of sessions) {
21
- const fileName = this.sessionFileName(session.id);
22
- expectedFileNames.add(fileName);
23
- fs.writeFileSync(path.join(this.sessionsDirPath, fileName), JSON.stringify(session, null, 2), "utf8");
24
- }
25
- try {
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.3",
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
+ }