openclaw-codex-feishu 0.1.1
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 +49 -0
- package/dist/commands/codex.schema.d.ts +103 -0
- package/dist/commands/codex.schema.js +42 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +24 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +66 -0
- package/dist/render/rawChunkRenderer.d.ts +12 -0
- package/dist/render/rawChunkRenderer.js +37 -0
- package/dist/render/transcriptNormalizer.d.ts +1 -0
- package/dist/render/transcriptNormalizer.js +12 -0
- package/dist/runtime/bridgeRuntime.d.ts +40 -0
- package/dist/runtime/bridgeRuntime.js +303 -0
- package/dist/state/bindingStore.d.ts +19 -0
- package/dist/state/bindingStore.js +58 -0
- package/dist/state/journalStore.d.ts +8 -0
- package/dist/state/journalStore.js +21 -0
- package/dist/state/schemas.d.ts +30 -0
- package/dist/state/schemas.js +1 -0
- package/dist/transport/appServerClient.d.ts +20 -0
- package/dist/transport/appServerClient.js +46 -0
- package/dist/transport/protocol.d.ts +22 -0
- package/dist/transport/protocol.js +1 -0
- package/dist/transport/stdioTransport.d.ts +17 -0
- package/dist/transport/stdioTransport.js +97 -0
- package/dist/utils/ids.d.ts +1 -0
- package/dist/utils/ids.js +4 -0
- package/dist/utils/keyedQueue.d.ts +4 -0
- package/dist/utils/keyedQueue.js +16 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.js +6 -0
- package/docs/cards/approval-card.json +21 -0
- package/docs/cards/control-card.json +26 -0
- package/docs/cards/turn-live-card.json +15 -0
- package/openclaw.plugin.json +122 -0
- package/package.json +49 -0
- package/scripts/install.mjs +34 -0
- package/skills/codex-bridge/SKILL.md +14 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveConfig } from "../config.js";
|
|
4
|
+
import { RawChunkRenderer } from "../render/rawChunkRenderer.js";
|
|
5
|
+
import { BindingStore } from "../state/bindingStore.js";
|
|
6
|
+
import { JournalStore } from "../state/journalStore.js";
|
|
7
|
+
import { AppServerClient } from "../transport/appServerClient.js";
|
|
8
|
+
import { KeyedQueue } from "../utils/keyedQueue.js";
|
|
9
|
+
export class BridgeRuntime {
|
|
10
|
+
logger;
|
|
11
|
+
chatSink;
|
|
12
|
+
config;
|
|
13
|
+
bindingStore;
|
|
14
|
+
journalStore;
|
|
15
|
+
renderer;
|
|
16
|
+
queue = new KeyedQueue();
|
|
17
|
+
client;
|
|
18
|
+
constructor(rawConfig, logger, chatSink) {
|
|
19
|
+
this.logger = logger;
|
|
20
|
+
this.chatSink = chatSink;
|
|
21
|
+
this.config = resolveConfig(rawConfig);
|
|
22
|
+
this.bindingStore = new BindingStore(this.config.dataDir);
|
|
23
|
+
this.journalStore = new JournalStore(this.config.dataDir);
|
|
24
|
+
this.renderer = new RawChunkRenderer(this, this.config.feishu.streamChunkChars ?? 1500);
|
|
25
|
+
this.client = new AppServerClient(this.config.command, this.config.args, logger);
|
|
26
|
+
this.client.onNotification((notification) => {
|
|
27
|
+
void this.handleNotification(notification.method, notification.params ?? {});
|
|
28
|
+
});
|
|
29
|
+
this.client.onExit(() => {
|
|
30
|
+
this.logger.error("Codex app-server transport exited unexpectedly");
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
await mkdir(this.config.dataDir, { recursive: true });
|
|
35
|
+
await this.client.initialize();
|
|
36
|
+
}
|
|
37
|
+
stop() {
|
|
38
|
+
this.client.stop();
|
|
39
|
+
}
|
|
40
|
+
async sendChunk(bindingKey, chunk) {
|
|
41
|
+
const [channel, accountId, peerKey] = bindingKey.split(":");
|
|
42
|
+
if (channel !== "feishu")
|
|
43
|
+
return;
|
|
44
|
+
await this.chatSink.sendText({ channel, accountId, peerKey }, chunk);
|
|
45
|
+
}
|
|
46
|
+
async handleCodexToolTask(identity, task, context) {
|
|
47
|
+
const input = context ? `${task}\n\nContext:\n${context}` : task;
|
|
48
|
+
await this.dispatchUserInput(identity, input);
|
|
49
|
+
return "Codex 已接管,输出将直接回流到当前 Feishu 聊天。";
|
|
50
|
+
}
|
|
51
|
+
async handleCodexCommand(identity, argsRaw) {
|
|
52
|
+
const args = argsRaw.trim();
|
|
53
|
+
if (!args) {
|
|
54
|
+
return "可用子命令: new | resume | status | detach | stop | steer | plan | raw | model | permissions | review | approve | replay | log";
|
|
55
|
+
}
|
|
56
|
+
const [subcommand, ...rest] = args.split(/\s+/g);
|
|
57
|
+
const tail = rest.join(" ").trim();
|
|
58
|
+
switch (subcommand) {
|
|
59
|
+
case "new":
|
|
60
|
+
return this.handleNew(identity, tail || this.config.defaultWorkspaceDir);
|
|
61
|
+
case "resume":
|
|
62
|
+
return this.handleResume(identity, tail);
|
|
63
|
+
case "status":
|
|
64
|
+
return this.handleStatus(identity);
|
|
65
|
+
case "detach":
|
|
66
|
+
return this.handleDetach(identity);
|
|
67
|
+
case "stop":
|
|
68
|
+
return this.handleStop(identity);
|
|
69
|
+
case "steer":
|
|
70
|
+
return this.handleSteer(identity, tail);
|
|
71
|
+
case "plan":
|
|
72
|
+
return this.handlePlan(identity, tail);
|
|
73
|
+
case "raw":
|
|
74
|
+
return this.handleRaw(identity, tail);
|
|
75
|
+
case "model":
|
|
76
|
+
return this.handleModel(identity, tail);
|
|
77
|
+
case "permissions":
|
|
78
|
+
return this.handlePermissions(identity, tail);
|
|
79
|
+
case "review":
|
|
80
|
+
return this.handleReview(identity, tail);
|
|
81
|
+
case "approve":
|
|
82
|
+
return this.handleApprove(identity, tail);
|
|
83
|
+
case "replay":
|
|
84
|
+
return this.handleReplay(identity, tail);
|
|
85
|
+
case "log":
|
|
86
|
+
return `日志目录: ${path.join(this.config.dataDir, "threads")}`;
|
|
87
|
+
default:
|
|
88
|
+
return this.dispatchUserInput(identity, args);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
async handleSteer(identity, text) {
|
|
92
|
+
if (!text)
|
|
93
|
+
return "用法: /codex steer <text>";
|
|
94
|
+
const state = await this.bindingStore.load(identity);
|
|
95
|
+
if (!state.threadId || !state.activeTurnId)
|
|
96
|
+
return "当前没有运行中的 turn。";
|
|
97
|
+
await this.client.turnSteer(state.threadId, state.activeTurnId, text);
|
|
98
|
+
await this.journalStore.appendEvent({
|
|
99
|
+
ts: Date.now(),
|
|
100
|
+
bindingKey: state.bindingKey,
|
|
101
|
+
threadId: state.threadId,
|
|
102
|
+
turnId: state.activeTurnId,
|
|
103
|
+
direction: "out",
|
|
104
|
+
raw: { method: "turn/steer", params: { input: text } }
|
|
105
|
+
});
|
|
106
|
+
return `已发送 steer 指令: ${text.slice(0, 100)}`;
|
|
107
|
+
}
|
|
108
|
+
async handlePlan(identity, goal) {
|
|
109
|
+
const input = goal ? `请先给出执行计划,再开始执行。\n\n目标:${goal}` : "请先给出执行计划,再开始执行。";
|
|
110
|
+
return this.dispatchUserInput(identity, input);
|
|
111
|
+
}
|
|
112
|
+
async handleNew(identity, workspaceRoot) {
|
|
113
|
+
const thread = (await this.client.threadStart(workspaceRoot, this.config.defaultModel));
|
|
114
|
+
const threadId = thread.threadId ?? "";
|
|
115
|
+
const state = await this.bindingStore.patch(identity, {
|
|
116
|
+
threadId,
|
|
117
|
+
workspaceRoot,
|
|
118
|
+
preferredModel: this.config.defaultModel,
|
|
119
|
+
status: "idle",
|
|
120
|
+
attachMode: this.config.attachModeDefault,
|
|
121
|
+
rawMode: this.config.rawModeDefault
|
|
122
|
+
});
|
|
123
|
+
await this.journalStore.writeMeta(threadId, {
|
|
124
|
+
workspaceRoot,
|
|
125
|
+
model: state.preferredModel,
|
|
126
|
+
createdAt: Date.now()
|
|
127
|
+
});
|
|
128
|
+
return `已创建 Codex 线程: ${threadId || "(未返回 threadId)"}`;
|
|
129
|
+
}
|
|
130
|
+
async handleResume(identity, threadId) {
|
|
131
|
+
if (!threadId)
|
|
132
|
+
return "用法: /codex resume <threadId>";
|
|
133
|
+
await this.client.threadResume(threadId);
|
|
134
|
+
await this.bindingStore.patch(identity, { threadId, status: "idle" });
|
|
135
|
+
return `已恢复线程: ${threadId}`;
|
|
136
|
+
}
|
|
137
|
+
async handleStatus(identity) {
|
|
138
|
+
const state = await this.bindingStore.load(identity);
|
|
139
|
+
return [
|
|
140
|
+
`bindingKey: ${state.bindingKey}`,
|
|
141
|
+
`status: ${state.status}`,
|
|
142
|
+
`threadId: ${state.threadId ?? "(none)"}`,
|
|
143
|
+
`workspace: ${state.workspaceRoot ?? "(none)"}`,
|
|
144
|
+
`model: ${state.preferredModel ?? this.config.defaultModel}`,
|
|
145
|
+
`rawMode: ${state.rawMode}`,
|
|
146
|
+
`attachMode: ${state.attachMode}`
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
async handleDetach(identity) {
|
|
150
|
+
await this.bindingStore.patch(identity, {
|
|
151
|
+
threadId: null,
|
|
152
|
+
activeTurnId: undefined,
|
|
153
|
+
status: "unbound"
|
|
154
|
+
});
|
|
155
|
+
return "已解绑当前聊天与 Codex 线程。";
|
|
156
|
+
}
|
|
157
|
+
async handleStop(identity) {
|
|
158
|
+
const state = await this.bindingStore.load(identity);
|
|
159
|
+
if (!state.threadId || !state.activeTurnId)
|
|
160
|
+
return "当前没有运行中的 turn。";
|
|
161
|
+
await this.client.turnInterrupt(state.threadId, state.activeTurnId);
|
|
162
|
+
await this.bindingStore.patch(identity, { status: "idle", activeTurnId: undefined });
|
|
163
|
+
return `已请求停止 turn: ${state.activeTurnId}`;
|
|
164
|
+
}
|
|
165
|
+
async handleRaw(identity, mode) {
|
|
166
|
+
if (!["off", "cli", "all"].includes(mode)) {
|
|
167
|
+
return "用法: /codex raw off|cli|all";
|
|
168
|
+
}
|
|
169
|
+
await this.bindingStore.patch(identity, { rawMode: mode });
|
|
170
|
+
return `raw 模式已更新为: ${mode}`;
|
|
171
|
+
}
|
|
172
|
+
async handleModel(identity, model) {
|
|
173
|
+
if (!model)
|
|
174
|
+
return "用法: /codex model <name>";
|
|
175
|
+
await this.bindingStore.patch(identity, { preferredModel: model });
|
|
176
|
+
return `默认模型已更新: ${model}`;
|
|
177
|
+
}
|
|
178
|
+
async handlePermissions(identity, mode) {
|
|
179
|
+
if (mode !== "default" && mode !== "full") {
|
|
180
|
+
return "用法: /codex permissions default|full";
|
|
181
|
+
}
|
|
182
|
+
const approvalMode = mode === "full" ? "fullAccess" : "default";
|
|
183
|
+
const state = await this.bindingStore.load(identity);
|
|
184
|
+
await this.bindingStore.save({ ...state, approvalMode, updatedAt: Date.now() });
|
|
185
|
+
return `权限模式已更新: ${approvalMode}`;
|
|
186
|
+
}
|
|
187
|
+
async handleReview(identity, focus) {
|
|
188
|
+
const state = await this.bindingStore.load(identity);
|
|
189
|
+
if (!state.threadId)
|
|
190
|
+
return "请先执行 /codex new 或 /codex resume";
|
|
191
|
+
await this.client.reviewStart(state.threadId, focus || undefined);
|
|
192
|
+
return "已提交 review 请求。";
|
|
193
|
+
}
|
|
194
|
+
async handleApprove(identity, tail) {
|
|
195
|
+
const [requestId, action, ...rest] = tail.split(/\s+/g).filter(Boolean);
|
|
196
|
+
if (!requestId || !action) {
|
|
197
|
+
return "用法: /codex approve <requestId> <allow-once|allow-always|deny|cancel|amend> [amendedCommand]";
|
|
198
|
+
}
|
|
199
|
+
const allowedActions = new Set(["allow-once", "allow-always", "deny", "cancel", "amend"]);
|
|
200
|
+
if (!allowedActions.has(action)) {
|
|
201
|
+
return "approve action 仅支持: allow-once | allow-always | deny | cancel | amend";
|
|
202
|
+
}
|
|
203
|
+
const amendedCommand = rest.join(" ").trim() || undefined;
|
|
204
|
+
await this.client.approve(requestId, action, amendedCommand);
|
|
205
|
+
const state = await this.bindingStore.load(identity);
|
|
206
|
+
if (state.threadId) {
|
|
207
|
+
await this.journalStore.appendEvent({
|
|
208
|
+
ts: Date.now(),
|
|
209
|
+
bindingKey: state.bindingKey,
|
|
210
|
+
threadId: state.threadId,
|
|
211
|
+
turnId: state.activeTurnId ?? "unknown",
|
|
212
|
+
direction: "out",
|
|
213
|
+
raw: { method: "approval/respond", params: { requestId, action, amendedCommand } }
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return `已提交审批响应: ${action} (${requestId})`;
|
|
217
|
+
}
|
|
218
|
+
async handleReplay(identity, turnId) {
|
|
219
|
+
const state = await this.bindingStore.load(identity);
|
|
220
|
+
if (!state.threadId)
|
|
221
|
+
return "请先执行 /codex new 或 /codex resume";
|
|
222
|
+
const targetTurnId = turnId || state.activeTurnId;
|
|
223
|
+
if (!targetTurnId)
|
|
224
|
+
return "用法: /codex replay <turnId>";
|
|
225
|
+
return `turn 日志路径: ${path.join(this.config.dataDir, "threads", state.threadId, "turns", `${targetTurnId}.jsonl`)}`;
|
|
226
|
+
}
|
|
227
|
+
async dispatchUserInput(identity, text) {
|
|
228
|
+
return this.queue.enqueue(`${identity.channel}:${identity.accountId}:${identity.peerKey}`, async () => {
|
|
229
|
+
const state = await this.bindingStore.load(identity);
|
|
230
|
+
if (!state.threadId) {
|
|
231
|
+
return "当前未绑定线程,请先执行 /codex new";
|
|
232
|
+
}
|
|
233
|
+
const turnResult = (await this.client.turnStart(state.threadId, text, {
|
|
234
|
+
approvalMode: state.approvalMode
|
|
235
|
+
}));
|
|
236
|
+
const turnId = turnResult.turnId ?? "";
|
|
237
|
+
await this.bindingStore.patch(identity, {
|
|
238
|
+
status: "running",
|
|
239
|
+
activeTurnId: turnId
|
|
240
|
+
});
|
|
241
|
+
await this.journalStore.appendEvent({
|
|
242
|
+
ts: Date.now(),
|
|
243
|
+
bindingKey: state.bindingKey,
|
|
244
|
+
threadId: state.threadId,
|
|
245
|
+
turnId,
|
|
246
|
+
direction: "out",
|
|
247
|
+
raw: { method: "turn/start", params: { input: text } }
|
|
248
|
+
});
|
|
249
|
+
return `已发送到 Codex: ${text.slice(0, 100)}`;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async handleNotification(method, params) {
|
|
253
|
+
const bindingKey = typeof params.bindingKey === "string" ? params.bindingKey : undefined;
|
|
254
|
+
const threadId = typeof params.threadId === "string" ? params.threadId : undefined;
|
|
255
|
+
const turnId = typeof params.turnId === "string" ? params.turnId : "unknown";
|
|
256
|
+
if (bindingKey && threadId) {
|
|
257
|
+
const event = {
|
|
258
|
+
ts: Date.now(),
|
|
259
|
+
bindingKey,
|
|
260
|
+
threadId,
|
|
261
|
+
turnId,
|
|
262
|
+
direction: "in",
|
|
263
|
+
raw: { method, params }
|
|
264
|
+
};
|
|
265
|
+
await this.journalStore.appendEvent(event);
|
|
266
|
+
const maybeText = this.extractRenderableText(method, params);
|
|
267
|
+
if (maybeText) {
|
|
268
|
+
const state = await this.bindingStore.load(this.identityFromBindingKey(bindingKey));
|
|
269
|
+
if (this.shouldRenderRaw(state.rawMode, method)) {
|
|
270
|
+
await this.renderer.push(bindingKey, maybeText, method === "turn/completed" || method === "error");
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
identityFromBindingKey(key) {
|
|
276
|
+
const [channel, accountId, peerKey] = key.split(":");
|
|
277
|
+
return { channel: channel ?? "feishu", accountId: accountId ?? "unknown", peerKey: peerKey ?? "unknown" };
|
|
278
|
+
}
|
|
279
|
+
shouldRenderRaw(rawMode, method) {
|
|
280
|
+
if (rawMode === "off")
|
|
281
|
+
return method === "error" || method === "turn/completed";
|
|
282
|
+
if (rawMode === "all")
|
|
283
|
+
return true;
|
|
284
|
+
// cli: only CLI-like text deltas and terminal events.
|
|
285
|
+
if (method === "error" || method === "turn/completed")
|
|
286
|
+
return true;
|
|
287
|
+
return method.includes("outputDelta") || method.includes("summaryTextDelta");
|
|
288
|
+
}
|
|
289
|
+
extractRenderableText(method, params) {
|
|
290
|
+
const delta = typeof params.delta === "string" ? params.delta : undefined;
|
|
291
|
+
const message = typeof params.message === "string" ? params.message : undefined;
|
|
292
|
+
if (method.includes("outputDelta") || method.includes("agentMessage") || method.includes("summaryTextDelta")) {
|
|
293
|
+
return delta ?? message;
|
|
294
|
+
}
|
|
295
|
+
if (method === "error") {
|
|
296
|
+
return `\n[error] ${message ?? "unknown error"}\n`;
|
|
297
|
+
}
|
|
298
|
+
if (method === "turn/completed") {
|
|
299
|
+
return "\n[turn completed]\n";
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AttachMode, RawMode } from "../config.js";
|
|
2
|
+
import type { BindingState } from "./schemas.js";
|
|
3
|
+
export interface BindingIdentity {
|
|
4
|
+
channel: string;
|
|
5
|
+
accountId: string;
|
|
6
|
+
peerKey: string;
|
|
7
|
+
}
|
|
8
|
+
export declare class BindingStore {
|
|
9
|
+
private readonly rootDir;
|
|
10
|
+
constructor(rootDir: string);
|
|
11
|
+
private get dir();
|
|
12
|
+
private fileForKey;
|
|
13
|
+
load(identity: BindingIdentity): Promise<BindingState>;
|
|
14
|
+
save(state: BindingState): Promise<void>;
|
|
15
|
+
patch(identity: BindingIdentity, patch: Partial<Pick<BindingState, "threadId" | "workspaceRoot" | "preferredModel" | "status" | "activeTurnId" | "controlMessageId" | "controlPinned">> & {
|
|
16
|
+
attachMode?: AttachMode;
|
|
17
|
+
rawMode?: RawMode;
|
|
18
|
+
}): Promise<BindingState>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
function bindingKey(identity) {
|
|
4
|
+
return `${identity.channel}:${identity.accountId}:${identity.peerKey}`;
|
|
5
|
+
}
|
|
6
|
+
export class BindingStore {
|
|
7
|
+
rootDir;
|
|
8
|
+
constructor(rootDir) {
|
|
9
|
+
this.rootDir = rootDir;
|
|
10
|
+
}
|
|
11
|
+
get dir() {
|
|
12
|
+
return path.join(this.rootDir, "bindings");
|
|
13
|
+
}
|
|
14
|
+
fileForKey(key) {
|
|
15
|
+
return path.join(this.dir, `${encodeURIComponent(key)}.json`);
|
|
16
|
+
}
|
|
17
|
+
async load(identity) {
|
|
18
|
+
const key = bindingKey(identity);
|
|
19
|
+
const file = this.fileForKey(key);
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(file, "utf8");
|
|
22
|
+
return JSON.parse(raw);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
return {
|
|
27
|
+
bindingKey: key,
|
|
28
|
+
channel: "feishu",
|
|
29
|
+
accountId: identity.accountId,
|
|
30
|
+
peerKey: identity.peerKey,
|
|
31
|
+
threadId: null,
|
|
32
|
+
workspaceRoot: null,
|
|
33
|
+
preferredModel: null,
|
|
34
|
+
approvalMode: "default",
|
|
35
|
+
rawMode: "cli",
|
|
36
|
+
attachMode: "attached",
|
|
37
|
+
status: "unbound",
|
|
38
|
+
createdAt: now,
|
|
39
|
+
updatedAt: now
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async save(state) {
|
|
44
|
+
await mkdir(this.dir, { recursive: true });
|
|
45
|
+
const file = this.fileForKey(state.bindingKey);
|
|
46
|
+
await writeFile(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
47
|
+
}
|
|
48
|
+
async patch(identity, patch) {
|
|
49
|
+
const current = await this.load(identity);
|
|
50
|
+
const next = {
|
|
51
|
+
...current,
|
|
52
|
+
...patch,
|
|
53
|
+
updatedAt: Date.now()
|
|
54
|
+
};
|
|
55
|
+
await this.save(next);
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { JournalEnvelope } from "./schemas.js";
|
|
2
|
+
export declare class JournalStore {
|
|
3
|
+
private readonly rootDir;
|
|
4
|
+
constructor(rootDir: string);
|
|
5
|
+
private threadDir;
|
|
6
|
+
writeMeta(threadId: string, meta: Record<string, unknown>): Promise<void>;
|
|
7
|
+
appendEvent(event: JournalEnvelope): Promise<void>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export class JournalStore {
|
|
4
|
+
rootDir;
|
|
5
|
+
constructor(rootDir) {
|
|
6
|
+
this.rootDir = rootDir;
|
|
7
|
+
}
|
|
8
|
+
threadDir(threadId) {
|
|
9
|
+
return path.join(this.rootDir, "threads", threadId);
|
|
10
|
+
}
|
|
11
|
+
async writeMeta(threadId, meta) {
|
|
12
|
+
const dir = this.threadDir(threadId);
|
|
13
|
+
await mkdir(dir, { recursive: true });
|
|
14
|
+
await writeFile(path.join(dir, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
|
|
15
|
+
}
|
|
16
|
+
async appendEvent(event) {
|
|
17
|
+
const turnDir = path.join(this.threadDir(event.threadId), "turns");
|
|
18
|
+
await mkdir(turnDir, { recursive: true });
|
|
19
|
+
await appendFile(path.join(turnDir, `${event.turnId}.jsonl`), `${JSON.stringify(event)}\n`, "utf8");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type ApprovalMode = "default" | "fullAccess";
|
|
2
|
+
export type RawMode = "off" | "cli" | "all";
|
|
3
|
+
export type AttachMode = "attached" | "toolOnly";
|
|
4
|
+
export type BindingStatus = "unbound" | "idle" | "running" | "awaitingApproval" | "error" | "transportStale";
|
|
5
|
+
export interface BindingState {
|
|
6
|
+
bindingKey: string;
|
|
7
|
+
channel: "feishu";
|
|
8
|
+
accountId: string;
|
|
9
|
+
peerKey: string;
|
|
10
|
+
threadId: string | null;
|
|
11
|
+
workspaceRoot: string | null;
|
|
12
|
+
preferredModel: string | null;
|
|
13
|
+
approvalMode: ApprovalMode;
|
|
14
|
+
rawMode: RawMode;
|
|
15
|
+
attachMode: AttachMode;
|
|
16
|
+
status: BindingStatus;
|
|
17
|
+
activeTurnId?: string;
|
|
18
|
+
controlMessageId?: string;
|
|
19
|
+
controlPinned?: boolean;
|
|
20
|
+
createdAt: number;
|
|
21
|
+
updatedAt: number;
|
|
22
|
+
}
|
|
23
|
+
export interface JournalEnvelope {
|
|
24
|
+
ts: number;
|
|
25
|
+
bindingKey: string;
|
|
26
|
+
threadId: string;
|
|
27
|
+
turnId: string;
|
|
28
|
+
direction: "in" | "out";
|
|
29
|
+
raw: Record<string, unknown>;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LoggerLike } from "../utils/logger.js";
|
|
2
|
+
import type { JsonRpcNotification } from "./protocol.js";
|
|
3
|
+
export declare class AppServerClient {
|
|
4
|
+
private readonly transport;
|
|
5
|
+
constructor(command: string, args: string[], logger: LoggerLike);
|
|
6
|
+
onNotification(listener: (n: JsonRpcNotification) => void): void;
|
|
7
|
+
onExit(listener: (reason: unknown) => void): void;
|
|
8
|
+
initialize(clientName?: string): Promise<unknown>;
|
|
9
|
+
threadStart(workspaceRoot: string, model: string): Promise<unknown>;
|
|
10
|
+
threadResume(threadId: string): Promise<unknown>;
|
|
11
|
+
turnStart(threadId: string, text: string, options?: {
|
|
12
|
+
approvalMode?: "default" | "fullAccess";
|
|
13
|
+
}): Promise<unknown>;
|
|
14
|
+
turnSteer(threadId: string, turnId: string, text: string): Promise<unknown>;
|
|
15
|
+
turnInterrupt(threadId: string, turnId: string): Promise<unknown>;
|
|
16
|
+
reviewStart(threadId: string, focus?: string): Promise<unknown>;
|
|
17
|
+
approve(requestId: string, action: string, amendedCommand?: string): Promise<unknown>;
|
|
18
|
+
notify(method: string, params?: Record<string, unknown>): void;
|
|
19
|
+
stop(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { StdioTransport } from "./stdioTransport.js";
|
|
2
|
+
export class AppServerClient {
|
|
3
|
+
transport;
|
|
4
|
+
constructor(command, args, logger) {
|
|
5
|
+
this.transport = new StdioTransport(command, args, logger);
|
|
6
|
+
}
|
|
7
|
+
onNotification(listener) {
|
|
8
|
+
this.transport.on("notification", listener);
|
|
9
|
+
}
|
|
10
|
+
onExit(listener) {
|
|
11
|
+
this.transport.on("exit", listener);
|
|
12
|
+
}
|
|
13
|
+
initialize(clientName = "openclaw-codex-feishu") {
|
|
14
|
+
return this.transport.request("initialize", {
|
|
15
|
+
clientName,
|
|
16
|
+
protocolVersion: "2026-03-01"
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
threadStart(workspaceRoot, model) {
|
|
20
|
+
return this.transport.request("thread/start", { workspaceRoot, model });
|
|
21
|
+
}
|
|
22
|
+
threadResume(threadId) {
|
|
23
|
+
return this.transport.request("thread/resume", { threadId });
|
|
24
|
+
}
|
|
25
|
+
turnStart(threadId, text, options) {
|
|
26
|
+
return this.transport.request("turn/start", { threadId, input: text, approvalMode: options?.approvalMode });
|
|
27
|
+
}
|
|
28
|
+
turnSteer(threadId, turnId, text) {
|
|
29
|
+
return this.transport.request("turn/steer", { threadId, turnId, input: text });
|
|
30
|
+
}
|
|
31
|
+
turnInterrupt(threadId, turnId) {
|
|
32
|
+
return this.transport.request("turn/interrupt", { threadId, turnId });
|
|
33
|
+
}
|
|
34
|
+
reviewStart(threadId, focus) {
|
|
35
|
+
return this.transport.request("review/start", { threadId, focus });
|
|
36
|
+
}
|
|
37
|
+
approve(requestId, action, amendedCommand) {
|
|
38
|
+
return this.transport.request("approval/respond", { requestId, action, amendedCommand });
|
|
39
|
+
}
|
|
40
|
+
notify(method, params) {
|
|
41
|
+
this.transport.notify(method, params);
|
|
42
|
+
}
|
|
43
|
+
stop() {
|
|
44
|
+
this.transport.stop();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface JsonRpcRequest {
|
|
2
|
+
jsonrpc: "2.0";
|
|
3
|
+
id: string;
|
|
4
|
+
method: string;
|
|
5
|
+
params?: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
export interface JsonRpcResponse {
|
|
8
|
+
jsonrpc: "2.0";
|
|
9
|
+
id: string;
|
|
10
|
+
result?: unknown;
|
|
11
|
+
error?: {
|
|
12
|
+
code: number;
|
|
13
|
+
message: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface JsonRpcNotification {
|
|
18
|
+
jsonrpc: "2.0";
|
|
19
|
+
method: string;
|
|
20
|
+
params?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
export type JsonRpcInbound = JsonRpcResponse | JsonRpcNotification;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import type { LoggerLike } from "../utils/logger.js";
|
|
3
|
+
export declare class StdioTransport extends EventEmitter {
|
|
4
|
+
private readonly command;
|
|
5
|
+
private readonly args;
|
|
6
|
+
private readonly logger;
|
|
7
|
+
private child?;
|
|
8
|
+
private readonly pending;
|
|
9
|
+
private buffer;
|
|
10
|
+
constructor(command: string, args: string[], logger: LoggerLike);
|
|
11
|
+
start(): void;
|
|
12
|
+
stop(): void;
|
|
13
|
+
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
14
|
+
notify(method: string, params?: Record<string, unknown>): void;
|
|
15
|
+
private flushLines;
|
|
16
|
+
private handleInbound;
|
|
17
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { makeId } from "../utils/ids.js";
|
|
4
|
+
export class StdioTransport extends EventEmitter {
|
|
5
|
+
command;
|
|
6
|
+
args;
|
|
7
|
+
logger;
|
|
8
|
+
child;
|
|
9
|
+
pending = new Map();
|
|
10
|
+
buffer = "";
|
|
11
|
+
constructor(command, args, logger) {
|
|
12
|
+
super();
|
|
13
|
+
this.command = command;
|
|
14
|
+
this.args = args;
|
|
15
|
+
this.logger = logger;
|
|
16
|
+
}
|
|
17
|
+
start() {
|
|
18
|
+
if (this.child && !this.child.killed)
|
|
19
|
+
return;
|
|
20
|
+
this.child = spawn(this.command, this.args, {
|
|
21
|
+
stdio: "pipe",
|
|
22
|
+
env: process.env
|
|
23
|
+
});
|
|
24
|
+
this.child.stdout.setEncoding("utf8");
|
|
25
|
+
this.child.stdout.on("data", (chunk) => {
|
|
26
|
+
this.buffer += chunk;
|
|
27
|
+
this.flushLines();
|
|
28
|
+
});
|
|
29
|
+
this.child.stderr.setEncoding("utf8");
|
|
30
|
+
this.child.stderr.on("data", (chunk) => {
|
|
31
|
+
this.logger.warn("codex-app-server stderr", chunk.trim());
|
|
32
|
+
});
|
|
33
|
+
this.child.on("exit", (code, signal) => {
|
|
34
|
+
this.logger.error(`codex-app-server exited code=${code} signal=${signal}`);
|
|
35
|
+
for (const [id, pending] of this.pending) {
|
|
36
|
+
pending.reject(new Error(`Transport closed before response for request ${id}`));
|
|
37
|
+
}
|
|
38
|
+
this.pending.clear();
|
|
39
|
+
this.emit("exit", { code, signal });
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
stop() {
|
|
43
|
+
this.child?.kill();
|
|
44
|
+
this.child = undefined;
|
|
45
|
+
}
|
|
46
|
+
async request(method, params) {
|
|
47
|
+
this.start();
|
|
48
|
+
const id = makeId("rpc");
|
|
49
|
+
const req = { jsonrpc: "2.0", id, method, params };
|
|
50
|
+
const raw = `${JSON.stringify(req)}\n`;
|
|
51
|
+
const promise = new Promise((resolve, reject) => {
|
|
52
|
+
this.pending.set(id, { resolve, reject });
|
|
53
|
+
});
|
|
54
|
+
this.child?.stdin.write(raw);
|
|
55
|
+
return promise;
|
|
56
|
+
}
|
|
57
|
+
notify(method, params) {
|
|
58
|
+
this.start();
|
|
59
|
+
const raw = `${JSON.stringify({ jsonrpc: "2.0", method, params })}\n`;
|
|
60
|
+
this.child?.stdin.write(raw);
|
|
61
|
+
}
|
|
62
|
+
flushLines() {
|
|
63
|
+
while (true) {
|
|
64
|
+
const idx = this.buffer.indexOf("\n");
|
|
65
|
+
if (idx < 0)
|
|
66
|
+
break;
|
|
67
|
+
const line = this.buffer.slice(0, idx).trim();
|
|
68
|
+
this.buffer = this.buffer.slice(idx + 1);
|
|
69
|
+
if (!line)
|
|
70
|
+
continue;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(line);
|
|
73
|
+
this.handleInbound(parsed);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
this.logger.warn("Failed to parse app-server JSON line", { line, error });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
handleInbound(inbound) {
|
|
81
|
+
if ("id" in inbound) {
|
|
82
|
+
const response = inbound;
|
|
83
|
+
const pending = this.pending.get(response.id);
|
|
84
|
+
if (!pending)
|
|
85
|
+
return;
|
|
86
|
+
this.pending.delete(response.id);
|
|
87
|
+
if (response.error) {
|
|
88
|
+
pending.reject(new Error(response.error.message));
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
pending.resolve(response.result);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
this.emit("notification", inbound);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function makeId(prefix: string): string;
|