openclaw-codex-app-server 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,228 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import fs from "node:fs/promises";
4
+ import { describe, expect, it } from "vitest";
5
+ import { PluginStateStore, buildPluginSessionKey } from "./state.js";
6
+
7
+ async function makeStoreDir(): Promise<string> {
8
+ return await fs.mkdtemp(path.join(os.tmpdir(), "oc-codex-plugin-"));
9
+ }
10
+
11
+ async function makeStore(dir?: string): Promise<PluginStateStore> {
12
+ const resolvedDir = dir ?? (await makeStoreDir());
13
+ const store = new PluginStateStore(resolvedDir);
14
+ await store.load();
15
+ return store;
16
+ }
17
+
18
+ describe("state store", () => {
19
+ it("persists bindings and callbacks", async () => {
20
+ const dir = await makeStoreDir();
21
+ const store = await makeStore(dir);
22
+ await store.upsertPendingBind({
23
+ conversation: {
24
+ channel: "telegram",
25
+ accountId: "default",
26
+ conversationId: "124",
27
+ },
28
+ threadId: "thread-pending",
29
+ workspaceDir: "/tmp/pending",
30
+ threadTitle: "Pending thread",
31
+ updatedAt: Date.now(),
32
+ });
33
+ await store.upsertBinding({
34
+ conversation: {
35
+ channel: "telegram",
36
+ accountId: "default",
37
+ conversationId: "123",
38
+ },
39
+ sessionKey: buildPluginSessionKey("thread-1"),
40
+ threadId: "thread-1",
41
+ workspaceDir: "/tmp/work",
42
+ contextUsage: {
43
+ totalTokens: 9_800,
44
+ contextWindow: 258_000,
45
+ remainingPercent: 96,
46
+ },
47
+ updatedAt: Date.now(),
48
+ });
49
+ const callback = await store.putCallback({
50
+ kind: "resume-thread",
51
+ conversation: {
52
+ channel: "telegram",
53
+ accountId: "default",
54
+ conversationId: "123",
55
+ },
56
+ threadId: "thread-1",
57
+ workspaceDir: "/tmp/work",
58
+ syncTopic: true,
59
+ });
60
+ const promptCallback = await store.putCallback({
61
+ kind: "run-prompt",
62
+ conversation: {
63
+ channel: "telegram",
64
+ accountId: "default",
65
+ conversationId: "123",
66
+ },
67
+ prompt: "Implement the plan.",
68
+ workspaceDir: "/tmp/work",
69
+ collaborationMode: {
70
+ mode: "default",
71
+ settings: {
72
+ model: "openai/gpt-5.4",
73
+ developerInstructions: null,
74
+ },
75
+ },
76
+ });
77
+ const modelCallback = await store.putCallback({
78
+ kind: "set-model",
79
+ conversation: {
80
+ channel: "telegram",
81
+ accountId: "default",
82
+ conversationId: "123",
83
+ },
84
+ model: "gpt-5.2-codex",
85
+ });
86
+ const replyCallback = await store.putCallback({
87
+ kind: "reply-text",
88
+ conversation: {
89
+ channel: "telegram",
90
+ accountId: "default",
91
+ conversationId: "123",
92
+ },
93
+ text: "Okay. Staying in plan mode.",
94
+ });
95
+ const reloaded = await makeStore(dir);
96
+
97
+ expect(reloaded.listBindings()).toHaveLength(1);
98
+ expect(reloaded.listBindings()[0]?.contextUsage?.totalTokens).toBe(9_800);
99
+ expect(reloaded.getPendingBind({
100
+ channel: "telegram",
101
+ accountId: "default",
102
+ conversationId: "124",
103
+ })?.threadId).toBe("thread-pending");
104
+ expect(reloaded.getCallback(callback.token)?.kind).toBe("resume-thread");
105
+ const resumeCallback = reloaded.getCallback(callback.token);
106
+ expect(resumeCallback?.kind).toBe("resume-thread");
107
+ expect(resumeCallback && resumeCallback.kind === "resume-thread" ? resumeCallback.syncTopic : undefined).toBe(true);
108
+ expect(reloaded.getCallback(promptCallback.token)?.kind).toBe("run-prompt");
109
+ const runPrompt = reloaded.getCallback(promptCallback.token);
110
+ expect(runPrompt && runPrompt.kind === "run-prompt" ? runPrompt.collaborationMode : undefined).toEqual({
111
+ mode: "default",
112
+ settings: {
113
+ model: "openai/gpt-5.4",
114
+ developerInstructions: null,
115
+ },
116
+ });
117
+ expect(reloaded.getCallback(modelCallback.token)?.kind).toBe("set-model");
118
+ expect(reloaded.getCallback(replyCallback.token)?.kind).toBe("reply-text");
119
+ });
120
+
121
+ it("removes pending requests and related callbacks", async () => {
122
+ const store = await makeStore();
123
+ await store.upsertPendingRequest({
124
+ requestId: "req-1",
125
+ conversation: {
126
+ channel: "discord",
127
+ accountId: "default",
128
+ conversationId: "chan-1",
129
+ },
130
+ threadId: "thread-1",
131
+ workspaceDir: "/tmp/work",
132
+ state: {
133
+ requestId: "req-1",
134
+ options: ["yes"],
135
+ expiresAt: Date.now() + 10_000,
136
+ },
137
+ updatedAt: Date.now(),
138
+ });
139
+ const callback = await store.putCallback({
140
+ kind: "pending-input",
141
+ conversation: {
142
+ channel: "discord",
143
+ accountId: "default",
144
+ conversationId: "chan-1",
145
+ },
146
+ requestId: "req-1",
147
+ actionIndex: 0,
148
+ });
149
+ const questionnaireCallback = await store.putCallback({
150
+ kind: "pending-questionnaire",
151
+ conversation: {
152
+ channel: "discord",
153
+ accountId: "default",
154
+ conversationId: "chan-1",
155
+ },
156
+ requestId: "req-1",
157
+ questionIndex: 0,
158
+ action: "select",
159
+ optionIndex: 0,
160
+ });
161
+ await store.removePendingRequest("req-1");
162
+ expect(store.getPendingRequestById("req-1")).toBeNull();
163
+ expect(store.getCallback(callback.token)).toBeNull();
164
+ expect(store.getCallback(questionnaireCallback.token)).toBeNull();
165
+ });
166
+
167
+ it("clears a pending bind when the binding is finalized", async () => {
168
+ const store = await makeStore();
169
+ await store.upsertPendingBind({
170
+ conversation: {
171
+ channel: "discord",
172
+ accountId: "default",
173
+ conversationId: "user:1",
174
+ },
175
+ threadId: "thread-1",
176
+ workspaceDir: "/tmp/work",
177
+ updatedAt: Date.now(),
178
+ });
179
+
180
+ await store.upsertBinding({
181
+ conversation: {
182
+ channel: "discord",
183
+ accountId: "default",
184
+ conversationId: "user:1",
185
+ },
186
+ sessionKey: buildPluginSessionKey("thread-1"),
187
+ threadId: "thread-1",
188
+ workspaceDir: "/tmp/work",
189
+ updatedAt: Date.now(),
190
+ });
191
+
192
+ expect(
193
+ store.getPendingBind({
194
+ channel: "discord",
195
+ accountId: "default",
196
+ conversationId: "user:1",
197
+ }),
198
+ ).toBeNull();
199
+ });
200
+
201
+ it("clears a pending bind when the conversation is explicitly removed", async () => {
202
+ const store = await makeStore();
203
+ await store.upsertPendingBind({
204
+ conversation: {
205
+ channel: "telegram",
206
+ accountId: "default",
207
+ conversationId: "123",
208
+ },
209
+ threadId: "thread-1",
210
+ workspaceDir: "/tmp/work",
211
+ updatedAt: Date.now(),
212
+ });
213
+
214
+ await store.removeBinding({
215
+ channel: "telegram",
216
+ accountId: "default",
217
+ conversationId: "123",
218
+ });
219
+
220
+ expect(
221
+ store.getPendingBind({
222
+ channel: "telegram",
223
+ accountId: "default",
224
+ conversationId: "123",
225
+ }),
226
+ ).toBeNull();
227
+ });
228
+ });
package/src/state.ts ADDED
@@ -0,0 +1,354 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import crypto from "node:crypto";
4
+ import { CALLBACK_TTL_MS, CALLBACK_TOKEN_BYTES, PLUGIN_ID, STORE_VERSION } from "./types.js";
5
+ import type {
6
+ CallbackAction,
7
+ CollaborationMode,
8
+ ConversationTarget,
9
+ StoreSnapshot,
10
+ StoredBinding,
11
+ StoredPendingBind,
12
+ StoredPendingRequest,
13
+ } from "./types.js";
14
+
15
+ type PutCallbackInput =
16
+ | {
17
+ kind: "resume-thread";
18
+ conversation: ConversationTarget;
19
+ threadId: string;
20
+ workspaceDir: string;
21
+ syncTopic?: boolean;
22
+ token?: string;
23
+ ttlMs?: number;
24
+ }
25
+ | {
26
+ kind: "pending-input";
27
+ conversation: ConversationTarget;
28
+ requestId: string;
29
+ actionIndex: number;
30
+ token?: string;
31
+ ttlMs?: number;
32
+ }
33
+ | {
34
+ kind: "pending-questionnaire";
35
+ conversation: ConversationTarget;
36
+ requestId: string;
37
+ questionIndex: number;
38
+ action: "select" | "prev" | "next" | "freeform";
39
+ optionIndex?: number;
40
+ token?: string;
41
+ ttlMs?: number;
42
+ }
43
+ | {
44
+ kind: "picker-view";
45
+ conversation: ConversationTarget;
46
+ view: Extract<CallbackAction, { kind: "picker-view" }>["view"];
47
+ token?: string;
48
+ ttlMs?: number;
49
+ }
50
+ | {
51
+ kind: "run-prompt";
52
+ conversation: ConversationTarget;
53
+ prompt: string;
54
+ workspaceDir?: string;
55
+ collaborationMode?: CollaborationMode;
56
+ token?: string;
57
+ ttlMs?: number;
58
+ }
59
+ | {
60
+ kind: "rename-thread";
61
+ conversation: ConversationTarget;
62
+ style: "thread-project" | "thread";
63
+ syncTopic: boolean;
64
+ token?: string;
65
+ ttlMs?: number;
66
+ }
67
+ | {
68
+ kind: "set-model";
69
+ conversation: ConversationTarget;
70
+ model: string;
71
+ token?: string;
72
+ ttlMs?: number;
73
+ }
74
+ | {
75
+ kind: "reply-text";
76
+ conversation: ConversationTarget;
77
+ text: string;
78
+ token?: string;
79
+ ttlMs?: number;
80
+ };
81
+
82
+ function toConversationKey(target: ConversationTarget): string {
83
+ const channel = target.channel.trim().toLowerCase();
84
+ return [
85
+ channel,
86
+ target.accountId.trim(),
87
+ target.conversationId.trim(),
88
+ channel === "telegram" ? (target.parentConversationId?.trim() ?? "") : "",
89
+ ].join("::");
90
+ }
91
+
92
+ function cloneSnapshot(value?: Partial<StoreSnapshot>): StoreSnapshot {
93
+ return {
94
+ version: STORE_VERSION,
95
+ bindings: value?.bindings ?? [],
96
+ pendingBinds: value?.pendingBinds ?? [],
97
+ pendingRequests: value?.pendingRequests ?? [],
98
+ callbacks: value?.callbacks ?? [],
99
+ };
100
+ }
101
+
102
+ export class PluginStateStore {
103
+ private snapshot = cloneSnapshot();
104
+
105
+ constructor(private readonly rootDir: string) {}
106
+
107
+ get dir(): string {
108
+ return path.join(this.rootDir, PLUGIN_ID);
109
+ }
110
+
111
+ get filePath(): string {
112
+ return path.join(this.dir, "state.json");
113
+ }
114
+
115
+ async load(): Promise<void> {
116
+ await fs.mkdir(this.dir, { recursive: true });
117
+ try {
118
+ const raw = await fs.readFile(this.filePath, "utf8");
119
+ const parsed = JSON.parse(raw) as Partial<StoreSnapshot>;
120
+ this.snapshot = cloneSnapshot(parsed);
121
+ this.pruneExpired();
122
+ await this.save();
123
+ } catch (error) {
124
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
125
+ throw error;
126
+ }
127
+ this.snapshot = cloneSnapshot();
128
+ await this.save();
129
+ }
130
+ }
131
+
132
+ async save(): Promise<void> {
133
+ await fs.mkdir(this.dir, { recursive: true });
134
+ await fs.writeFile(this.filePath, `${JSON.stringify(this.snapshot, null, 2)}\n`, "utf8");
135
+ }
136
+
137
+ pruneExpired(now = Date.now()): void {
138
+ this.snapshot.pendingBinds = this.snapshot.pendingBinds.filter(
139
+ (entry) => now - entry.updatedAt < CALLBACK_TTL_MS,
140
+ );
141
+ this.snapshot.pendingRequests = this.snapshot.pendingRequests.filter(
142
+ (entry) => entry.state.expiresAt > now,
143
+ );
144
+ this.snapshot.callbacks = this.snapshot.callbacks.filter((entry) => entry.expiresAt > now);
145
+ }
146
+
147
+ listBindings(): StoredBinding[] {
148
+ return [...this.snapshot.bindings];
149
+ }
150
+
151
+ getBinding(target: ConversationTarget): StoredBinding | null {
152
+ const key = toConversationKey(target);
153
+ return this.snapshot.bindings.find((entry) => toConversationKey(entry.conversation) === key) ?? null;
154
+ }
155
+
156
+ async upsertBinding(binding: StoredBinding): Promise<void> {
157
+ const key = toConversationKey(binding.conversation);
158
+ this.snapshot.bindings = this.snapshot.bindings.filter(
159
+ (entry) => toConversationKey(entry.conversation) !== key,
160
+ );
161
+ this.snapshot.pendingBinds = this.snapshot.pendingBinds.filter(
162
+ (entry) => toConversationKey(entry.conversation) !== key,
163
+ );
164
+ this.snapshot.bindings.push(binding);
165
+ await this.save();
166
+ }
167
+
168
+ async removeBinding(target: ConversationTarget): Promise<void> {
169
+ const key = toConversationKey(target);
170
+ this.snapshot.bindings = this.snapshot.bindings.filter(
171
+ (entry) => toConversationKey(entry.conversation) !== key,
172
+ );
173
+ this.snapshot.pendingBinds = this.snapshot.pendingBinds.filter(
174
+ (entry) => toConversationKey(entry.conversation) !== key,
175
+ );
176
+ this.snapshot.pendingRequests = this.snapshot.pendingRequests.filter(
177
+ (entry) => toConversationKey(entry.conversation) !== key,
178
+ );
179
+ this.snapshot.callbacks = this.snapshot.callbacks.filter(
180
+ (entry) => toConversationKey(entry.conversation) !== key,
181
+ );
182
+ await this.save();
183
+ }
184
+
185
+ getPendingRequestByConversation(target: ConversationTarget): StoredPendingRequest | null {
186
+ const key = toConversationKey(target);
187
+ return (
188
+ this.snapshot.pendingRequests.find((entry) => toConversationKey(entry.conversation) === key) ??
189
+ null
190
+ );
191
+ }
192
+
193
+ getPendingBind(target: ConversationTarget): StoredPendingBind | null {
194
+ const key = toConversationKey(target);
195
+ return (
196
+ this.snapshot.pendingBinds.find((entry) => toConversationKey(entry.conversation) === key) ??
197
+ null
198
+ );
199
+ }
200
+
201
+ async upsertPendingBind(entry: StoredPendingBind): Promise<void> {
202
+ const key = toConversationKey(entry.conversation);
203
+ this.snapshot.pendingBinds = this.snapshot.pendingBinds.filter(
204
+ (current) => toConversationKey(current.conversation) !== key,
205
+ );
206
+ this.snapshot.pendingBinds.push(entry);
207
+ await this.save();
208
+ }
209
+
210
+ async removePendingBind(target: ConversationTarget): Promise<void> {
211
+ const key = toConversationKey(target);
212
+ this.snapshot.pendingBinds = this.snapshot.pendingBinds.filter(
213
+ (entry) => toConversationKey(entry.conversation) !== key,
214
+ );
215
+ await this.save();
216
+ }
217
+
218
+ getPendingRequestById(requestId: string): StoredPendingRequest | null {
219
+ return this.snapshot.pendingRequests.find((entry) => entry.requestId === requestId) ?? null;
220
+ }
221
+
222
+ async upsertPendingRequest(entry: StoredPendingRequest): Promise<void> {
223
+ this.snapshot.pendingRequests = this.snapshot.pendingRequests.filter(
224
+ (current) => current.requestId !== entry.requestId,
225
+ );
226
+ this.snapshot.pendingRequests.push(entry);
227
+ await this.save();
228
+ }
229
+
230
+ async removePendingRequest(requestId: string): Promise<void> {
231
+ this.snapshot.pendingRequests = this.snapshot.pendingRequests.filter(
232
+ (entry) => entry.requestId !== requestId,
233
+ );
234
+ this.snapshot.callbacks = this.snapshot.callbacks.filter((entry) => {
235
+ if (entry.kind !== "pending-input" && entry.kind !== "pending-questionnaire") {
236
+ return true;
237
+ }
238
+ return entry.requestId !== requestId;
239
+ });
240
+ await this.save();
241
+ }
242
+
243
+ createCallbackToken(): string {
244
+ return crypto.randomBytes(CALLBACK_TOKEN_BYTES).toString("base64url");
245
+ }
246
+
247
+ async putCallback(callback: PutCallbackInput): Promise<CallbackAction> {
248
+ const now = Date.now();
249
+ const entry: CallbackAction =
250
+ callback.kind === "resume-thread"
251
+ ? {
252
+ kind: "resume-thread",
253
+ conversation: callback.conversation,
254
+ threadId: callback.threadId,
255
+ workspaceDir: callback.workspaceDir,
256
+ syncTopic: callback.syncTopic,
257
+ token: callback.token ?? this.createCallbackToken(),
258
+ createdAt: now,
259
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
260
+ }
261
+ : callback.kind === "pending-input"
262
+ ? {
263
+ kind: "pending-input",
264
+ conversation: callback.conversation,
265
+ requestId: callback.requestId,
266
+ actionIndex: callback.actionIndex,
267
+ token: callback.token ?? this.createCallbackToken(),
268
+ createdAt: now,
269
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
270
+ }
271
+ : callback.kind === "pending-questionnaire"
272
+ ? {
273
+ kind: "pending-questionnaire",
274
+ conversation: callback.conversation,
275
+ requestId: callback.requestId,
276
+ questionIndex: callback.questionIndex,
277
+ action: callback.action,
278
+ optionIndex: callback.optionIndex,
279
+ token: callback.token ?? this.createCallbackToken(),
280
+ createdAt: now,
281
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
282
+ }
283
+ : callback.kind === "picker-view"
284
+ ? {
285
+ kind: "picker-view",
286
+ conversation: callback.conversation,
287
+ view: callback.view,
288
+ token: callback.token ?? this.createCallbackToken(),
289
+ createdAt: now,
290
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
291
+ }
292
+ : callback.kind === "run-prompt"
293
+ ? {
294
+ kind: "run-prompt",
295
+ conversation: callback.conversation,
296
+ prompt: callback.prompt,
297
+ workspaceDir: callback.workspaceDir,
298
+ collaborationMode: callback.collaborationMode,
299
+ token: callback.token ?? this.createCallbackToken(),
300
+ createdAt: now,
301
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
302
+ }
303
+ : callback.kind === "rename-thread"
304
+ ? {
305
+ kind: "rename-thread",
306
+ conversation: callback.conversation,
307
+ style: callback.style,
308
+ syncTopic: callback.syncTopic,
309
+ token: callback.token ?? this.createCallbackToken(),
310
+ createdAt: now,
311
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
312
+ }
313
+ : callback.kind === "set-model"
314
+ ? {
315
+ kind: "set-model",
316
+ conversation: callback.conversation,
317
+ model: callback.model,
318
+ token: callback.token ?? this.createCallbackToken(),
319
+ createdAt: now,
320
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
321
+ }
322
+ : {
323
+ kind: "reply-text",
324
+ conversation: callback.conversation,
325
+ text: callback.text,
326
+ token: callback.token ?? this.createCallbackToken(),
327
+ createdAt: now,
328
+ expiresAt: now + (callback.ttlMs ?? CALLBACK_TTL_MS),
329
+ };
330
+ this.snapshot.callbacks = this.snapshot.callbacks.filter(
331
+ (current) => current.token !== entry.token,
332
+ );
333
+ this.snapshot.callbacks.push(entry);
334
+ await this.save();
335
+ return entry;
336
+ }
337
+
338
+ getCallback(token: string): CallbackAction | null {
339
+ return this.snapshot.callbacks.find((entry) => entry.token === token) ?? null;
340
+ }
341
+
342
+ async removeCallback(token: string): Promise<void> {
343
+ this.snapshot.callbacks = this.snapshot.callbacks.filter((entry) => entry.token !== token);
344
+ await this.save();
345
+ }
346
+ }
347
+
348
+ export function buildPluginSessionKey(threadId: string): string {
349
+ return `${PLUGIN_ID}:thread:${threadId.trim()}`;
350
+ }
351
+
352
+ export function buildConversationKey(target: ConversationTarget): string {
353
+ return toConversationKey(target);
354
+ }
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ getProjectName,
4
+ listProjects,
5
+ paginateItems,
6
+ } from "./thread-picker.js";
7
+
8
+ describe("thread picker helpers", () => {
9
+ it("derives the project name from a worktree path", () => {
10
+ expect(getProjectName("/Users/huntharo/.codex/worktrees/cb00/openclaw")).toBe("openclaw");
11
+ });
12
+
13
+ it("groups multiple worktrees under the same project name", () => {
14
+ expect(
15
+ listProjects([
16
+ {
17
+ threadId: "1",
18
+ title: "One",
19
+ projectKey: "/Users/huntharo/.codex/worktrees/cb00/openclaw",
20
+ updatedAt: 10,
21
+ },
22
+ {
23
+ threadId: "2",
24
+ title: "Two",
25
+ projectKey: "/Users/huntharo/.codex/worktrees/cb01/openclaw",
26
+ updatedAt: 20,
27
+ },
28
+ {
29
+ threadId: "3",
30
+ title: "Three",
31
+ projectKey: "/Users/huntharo/github/gitcrawl",
32
+ updatedAt: 5,
33
+ },
34
+ ]),
35
+ ).toEqual([
36
+ { name: "openclaw", threadCount: 2, latestUpdatedAt: 20 },
37
+ { name: "gitcrawl", threadCount: 1, latestUpdatedAt: 5 },
38
+ ]);
39
+ });
40
+
41
+ it("paginates thread pickers without skipping items", () => {
42
+ const page = paginateItems(["a", "b", "c", "d", "e"], 1, 2);
43
+ expect(page.items).toEqual(["c", "d"]);
44
+ expect(page.page).toBe(1);
45
+ expect(page.totalPages).toBe(3);
46
+ });
47
+ });