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.
- package/AGENTS.md +22 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/index.ts +69 -0
- package/openclaw.plugin.json +105 -0
- package/package.json +28 -0
- package/src/client.test.ts +332 -0
- package/src/client.ts +2914 -0
- package/src/config.ts +103 -0
- package/src/controller.test.ts +1177 -0
- package/src/controller.ts +3232 -0
- package/src/format.test.ts +502 -0
- package/src/format.ts +869 -0
- package/src/openclaw-plugin-sdk.d.ts +237 -0
- package/src/pending-input.test.ts +298 -0
- package/src/pending-input.ts +785 -0
- package/src/state.test.ts +228 -0
- package/src/state.ts +354 -0
- package/src/thread-picker.test.ts +47 -0
- package/src/thread-picker.ts +98 -0
- package/src/thread-selection.test.ts +89 -0
- package/src/thread-selection.ts +106 -0
- package/src/types.ts +372 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,3232 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promises as fs } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import type {
|
|
7
|
+
OpenClawPluginApi,
|
|
8
|
+
OpenClawPluginService,
|
|
9
|
+
PluginCommandContext,
|
|
10
|
+
PluginInteractiveButtons,
|
|
11
|
+
PluginInteractiveDiscordHandlerContext,
|
|
12
|
+
PluginInteractiveTelegramHandlerContext,
|
|
13
|
+
ReplyPayload,
|
|
14
|
+
ConversationRef,
|
|
15
|
+
} from "openclaw/plugin-sdk";
|
|
16
|
+
import { resolvePluginSettings, resolveWorkspaceDir } from "./config.js";
|
|
17
|
+
import { CodexAppServerClient, type ActiveCodexRun, isMissingThreadError } from "./client.js";
|
|
18
|
+
import {
|
|
19
|
+
formatAccountSummary,
|
|
20
|
+
formatBinding,
|
|
21
|
+
formatBoundThreadSummary,
|
|
22
|
+
formatCodexPlanAttachmentFallback,
|
|
23
|
+
formatCodexPlanAttachmentSummary,
|
|
24
|
+
formatCodexPlanInlineText,
|
|
25
|
+
formatCodexReviewFindingMessage,
|
|
26
|
+
formatCodexStatusText,
|
|
27
|
+
formatExperimentalFeatures,
|
|
28
|
+
formatMcpServers,
|
|
29
|
+
formatModels,
|
|
30
|
+
parseCodexReviewOutput,
|
|
31
|
+
formatProjectPickerIntro,
|
|
32
|
+
formatReviewCompletion,
|
|
33
|
+
formatSkills,
|
|
34
|
+
formatThreadButtonLabel,
|
|
35
|
+
formatThreadPickerIntro,
|
|
36
|
+
formatThreadState,
|
|
37
|
+
formatTurnCompletion,
|
|
38
|
+
} from "./format.js";
|
|
39
|
+
import type { CollaborationMode } from "./types.js";
|
|
40
|
+
import {
|
|
41
|
+
buildPendingQuestionnaireResponse,
|
|
42
|
+
formatPendingQuestionnairePrompt,
|
|
43
|
+
questionnaireCurrentQuestionHasAnswer,
|
|
44
|
+
questionnaireIsComplete,
|
|
45
|
+
requestToken,
|
|
46
|
+
} from "./pending-input.js";
|
|
47
|
+
import {
|
|
48
|
+
buildConversationKey,
|
|
49
|
+
buildPluginSessionKey,
|
|
50
|
+
PluginStateStore,
|
|
51
|
+
} from "./state.js";
|
|
52
|
+
import {
|
|
53
|
+
parseThreadSelectionArgs,
|
|
54
|
+
selectThreadFromMatches,
|
|
55
|
+
} from "./thread-selection.js";
|
|
56
|
+
import {
|
|
57
|
+
filterThreadsByProjectName,
|
|
58
|
+
getProjectName,
|
|
59
|
+
listProjects,
|
|
60
|
+
paginateItems,
|
|
61
|
+
} from "./thread-picker.js";
|
|
62
|
+
import {
|
|
63
|
+
INTERACTIVE_NAMESPACE,
|
|
64
|
+
PLUGIN_ID,
|
|
65
|
+
type CallbackAction,
|
|
66
|
+
type ConversationTarget,
|
|
67
|
+
type PendingInputState,
|
|
68
|
+
type StoredBinding,
|
|
69
|
+
type StoredPendingRequest,
|
|
70
|
+
} from "./types.js";
|
|
71
|
+
|
|
72
|
+
type ActiveRunRecord = {
|
|
73
|
+
conversation: ConversationTarget;
|
|
74
|
+
workspaceDir: string;
|
|
75
|
+
mode: "default" | "plan" | "review";
|
|
76
|
+
handle: ActiveCodexRun;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const execFileAsync = promisify(execFile);
|
|
80
|
+
|
|
81
|
+
type PickerRender = {
|
|
82
|
+
text: string;
|
|
83
|
+
buttons: PluginInteractiveButtons | undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
type PickerResponders = {
|
|
87
|
+
conversation: ConversationTarget;
|
|
88
|
+
clear: () => Promise<void>;
|
|
89
|
+
reply: (text: string) => Promise<void>;
|
|
90
|
+
editPicker: (picker: PickerRender) => Promise<void>;
|
|
91
|
+
requestConversationBinding?: (
|
|
92
|
+
params?: { summary?: string },
|
|
93
|
+
) => Promise<
|
|
94
|
+
| { status: "bound" }
|
|
95
|
+
| { status: "pending"; reply: ReplyPayload }
|
|
96
|
+
| { status: "error"; message: string }
|
|
97
|
+
>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type ScopedBindingApi = {
|
|
101
|
+
requestConversationBinding?: (
|
|
102
|
+
params?: { summary?: string },
|
|
103
|
+
) => Promise<
|
|
104
|
+
| { status: "bound" }
|
|
105
|
+
| { status: "pending"; reply: ReplyPayload }
|
|
106
|
+
| { status: "error"; message: string }
|
|
107
|
+
>;
|
|
108
|
+
detachConversationBinding?: () => Promise<{ removed: boolean }>;
|
|
109
|
+
getCurrentConversationBinding?: () => Promise<unknown>;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
type FollowUpSummary = {
|
|
113
|
+
initialReply: ReplyPayload;
|
|
114
|
+
followUps: string[];
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
type PlanDelivery = {
|
|
118
|
+
summaryText: string;
|
|
119
|
+
attachmentPath?: string;
|
|
120
|
+
attachmentFallbackText?: string;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
124
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
125
|
+
? (value as Record<string, unknown>)
|
|
126
|
+
: null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function asScopedBindingApi(value: object): ScopedBindingApi {
|
|
130
|
+
return value as ScopedBindingApi;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isTelegramChannel(channel: string): boolean {
|
|
134
|
+
return channel.trim().toLowerCase() === "telegram";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isDiscordChannel(channel: string): boolean {
|
|
138
|
+
return channel.trim().toLowerCase() === "discord";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildPlainReply(text: string): ReplyPayload {
|
|
142
|
+
return { text };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function normalizeTelegramChatId(raw: string | undefined): string | undefined {
|
|
146
|
+
if (!raw) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
const trimmed = raw.trim();
|
|
150
|
+
if (!trimmed) {
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
153
|
+
if (trimmed.startsWith("telegram:")) {
|
|
154
|
+
return trimmed.slice("telegram:".length);
|
|
155
|
+
}
|
|
156
|
+
return trimmed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeDiscordConversationId(raw: string | undefined): string | undefined {
|
|
160
|
+
if (!raw) {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
const trimmed = raw.trim();
|
|
164
|
+
if (!trimmed) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
if (trimmed.startsWith("discord:channel:")) {
|
|
168
|
+
return `channel:${trimmed.slice("discord:channel:".length)}`;
|
|
169
|
+
}
|
|
170
|
+
if (trimmed.startsWith("discord:group:")) {
|
|
171
|
+
return `channel:${trimmed.slice("discord:group:".length)}`;
|
|
172
|
+
}
|
|
173
|
+
if (trimmed.startsWith("discord:user:")) {
|
|
174
|
+
return `user:${trimmed.slice("discord:user:".length)}`;
|
|
175
|
+
}
|
|
176
|
+
if (trimmed.startsWith("discord:")) {
|
|
177
|
+
return `user:${trimmed.slice("discord:".length)}`;
|
|
178
|
+
}
|
|
179
|
+
if (trimmed.startsWith("slash:")) {
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
return trimmed;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function denormalizeDiscordConversationId(raw: string | undefined): string | undefined {
|
|
186
|
+
if (!raw) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
const trimmed = raw.trim();
|
|
190
|
+
if (!trimmed) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
if (trimmed.startsWith("channel:")) {
|
|
194
|
+
return trimmed.slice("channel:".length);
|
|
195
|
+
}
|
|
196
|
+
if (trimmed.startsWith("user:")) {
|
|
197
|
+
return trimmed.slice("user:".length);
|
|
198
|
+
}
|
|
199
|
+
if (trimmed.startsWith("discord:channel:")) {
|
|
200
|
+
return trimmed.slice("discord:channel:".length);
|
|
201
|
+
}
|
|
202
|
+
if (trimmed.startsWith("discord:user:")) {
|
|
203
|
+
return trimmed.slice("discord:user:".length);
|
|
204
|
+
}
|
|
205
|
+
if (trimmed.startsWith("discord:")) {
|
|
206
|
+
return trimmed.slice("discord:".length);
|
|
207
|
+
}
|
|
208
|
+
return trimmed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function normalizeDiscordInteractiveConversationId(params: {
|
|
212
|
+
conversationId?: string;
|
|
213
|
+
guildId?: string;
|
|
214
|
+
}): string | undefined {
|
|
215
|
+
const normalized = normalizeDiscordConversationId(params.conversationId);
|
|
216
|
+
if (!normalized) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
if (normalized.includes(":")) {
|
|
220
|
+
return normalized;
|
|
221
|
+
}
|
|
222
|
+
return params.guildId ? `channel:${normalized}` : `user:${normalized}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function toConversationTargetFromCommand(ctx: PluginCommandContext): ConversationTarget | null {
|
|
226
|
+
if (isTelegramChannel(ctx.channel)) {
|
|
227
|
+
const chatId = normalizeTelegramChatId(ctx.to ?? ctx.from ?? ctx.senderId);
|
|
228
|
+
if (!chatId) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
channel: "telegram",
|
|
233
|
+
accountId: ctx.accountId ?? "default",
|
|
234
|
+
conversationId:
|
|
235
|
+
typeof ctx.messageThreadId === "number" ? `${chatId}:topic:${ctx.messageThreadId}` : chatId,
|
|
236
|
+
parentConversationId: typeof ctx.messageThreadId === "number" ? chatId : undefined,
|
|
237
|
+
threadId: ctx.messageThreadId,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
if (isDiscordChannel(ctx.channel)) {
|
|
241
|
+
const conversationId = normalizeDiscordConversationId(ctx.from ?? ctx.to);
|
|
242
|
+
if (!conversationId) {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
channel: "discord",
|
|
247
|
+
accountId: ctx.accountId ?? "default",
|
|
248
|
+
conversationId,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function toConversationTargetFromInbound(event: {
|
|
255
|
+
channel: string;
|
|
256
|
+
accountId?: string;
|
|
257
|
+
conversationId?: string;
|
|
258
|
+
parentConversationId?: string;
|
|
259
|
+
threadId?: string | number;
|
|
260
|
+
isGroup?: boolean;
|
|
261
|
+
metadata?: Record<string, unknown>;
|
|
262
|
+
}): ConversationTarget | null {
|
|
263
|
+
if (!event.accountId || !event.conversationId) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
const channel = event.channel.trim().toLowerCase();
|
|
267
|
+
const conversationIdRaw = event.conversationId?.trim();
|
|
268
|
+
const conversationId =
|
|
269
|
+
channel === "discord"
|
|
270
|
+
? (() => {
|
|
271
|
+
const normalized = normalizeDiscordConversationId(conversationIdRaw);
|
|
272
|
+
if (!normalized) {
|
|
273
|
+
return undefined;
|
|
274
|
+
}
|
|
275
|
+
if (normalized.includes(":")) {
|
|
276
|
+
return normalized;
|
|
277
|
+
}
|
|
278
|
+
const guildId =
|
|
279
|
+
typeof event.metadata?.guildId === "string" ? event.metadata.guildId.trim() : "";
|
|
280
|
+
const isChannel = Boolean(event.parentConversationId?.trim() || event.isGroup || guildId);
|
|
281
|
+
return `${isChannel ? "channel" : "user"}:${normalized}`;
|
|
282
|
+
})()
|
|
283
|
+
: event.conversationId;
|
|
284
|
+
const parentConversationId =
|
|
285
|
+
channel === "discord"
|
|
286
|
+
? normalizeDiscordConversationId(event.parentConversationId)
|
|
287
|
+
: event.parentConversationId;
|
|
288
|
+
if (!conversationId) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
channel,
|
|
293
|
+
accountId: event.accountId,
|
|
294
|
+
conversationId,
|
|
295
|
+
parentConversationId,
|
|
296
|
+
threadId:
|
|
297
|
+
typeof event.threadId === "number"
|
|
298
|
+
? event.threadId
|
|
299
|
+
: typeof event.threadId === "string"
|
|
300
|
+
? Number.isFinite(Number(event.threadId))
|
|
301
|
+
? Number(event.threadId)
|
|
302
|
+
: undefined
|
|
303
|
+
: undefined,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function buildReplyWithButtons(text: string, buttons?: PluginInteractiveButtons): ReplyPayload {
|
|
308
|
+
return buttons
|
|
309
|
+
? {
|
|
310
|
+
text,
|
|
311
|
+
channelData: {
|
|
312
|
+
telegram: {
|
|
313
|
+
buttons,
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
: { text };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function parseFastAction(
|
|
321
|
+
argsText: string,
|
|
322
|
+
): "toggle" | "on" | "off" | "status" | { error: string } {
|
|
323
|
+
const normalized = argsText.trim().toLowerCase();
|
|
324
|
+
if (!normalized) {
|
|
325
|
+
return "toggle";
|
|
326
|
+
}
|
|
327
|
+
if (normalized === "on" || normalized === "off" || normalized === "status") {
|
|
328
|
+
return normalized;
|
|
329
|
+
}
|
|
330
|
+
return { error: "Usage: /codex_fast [on|off|status]" };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function normalizeServiceTier(value: string | undefined | null): string | undefined {
|
|
334
|
+
const normalized = value?.trim().toLowerCase();
|
|
335
|
+
return normalized ? normalized : undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function formatFastModeValue(value: string | undefined): string {
|
|
339
|
+
const normalized = normalizeServiceTier(value);
|
|
340
|
+
if (!normalized || normalized === "default" || normalized === "auto") {
|
|
341
|
+
return "off";
|
|
342
|
+
}
|
|
343
|
+
if (normalized === "fast" || normalized === "priority") {
|
|
344
|
+
return "on";
|
|
345
|
+
}
|
|
346
|
+
return normalized;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const PLAN_PROGRESS_DELAY_MS = 12_000;
|
|
350
|
+
const REVIEW_PROGRESS_DELAY_MS = 12_000;
|
|
351
|
+
const COMPACT_PROGRESS_DELAY_MS = 12_000;
|
|
352
|
+
const COMPACT_PROGRESS_INTERVAL_MS = 15_000;
|
|
353
|
+
const PLAN_INLINE_TEXT_LIMIT = 2600;
|
|
354
|
+
|
|
355
|
+
function isTransportClosedMessage(error: unknown): boolean {
|
|
356
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
357
|
+
const normalized = text.trim().toLowerCase();
|
|
358
|
+
return (
|
|
359
|
+
normalized.includes("stdio not connected") ||
|
|
360
|
+
normalized.includes("websocket not connected") ||
|
|
361
|
+
normalized.includes("stdio closed") ||
|
|
362
|
+
normalized.includes("websocket closed") ||
|
|
363
|
+
normalized.includes("socket closed") ||
|
|
364
|
+
normalized.includes("broken pipe")
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function formatFailureText(kind: "plan" | "review" | "compact", error: unknown): string {
|
|
369
|
+
if (isTransportClosedMessage(error)) {
|
|
370
|
+
return `Codex ${kind} failed because the App Server connection closed. Please retry the command or rejoin the thread.`;
|
|
371
|
+
}
|
|
372
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
373
|
+
return `Codex ${kind} failed: ${message}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function formatInterruptedText(kind: "plan" | "review"): string {
|
|
377
|
+
return `Codex ${kind} was interrupted before it finished.`;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function formatContextUsageText(usage: { totalTokens?: number; contextWindow?: number }): string | undefined {
|
|
381
|
+
if (typeof usage.totalTokens !== "number") {
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
const total = usage.totalTokens >= 1000 ? `${(usage.totalTokens / 1000).toFixed(usage.totalTokens >= 10000 ? 0 : 1)}k` : String(usage.totalTokens);
|
|
385
|
+
const context =
|
|
386
|
+
typeof usage.contextWindow === "number"
|
|
387
|
+
? usage.contextWindow >= 1000
|
|
388
|
+
? `${(usage.contextWindow / 1000).toFixed(usage.contextWindow >= 10000 ? 0 : 1)}k`
|
|
389
|
+
: String(usage.contextWindow)
|
|
390
|
+
: "?";
|
|
391
|
+
const percent =
|
|
392
|
+
typeof usage.contextWindow === "number" && usage.contextWindow > 0
|
|
393
|
+
? Math.round((usage.totalTokens / usage.contextWindow) * 100)
|
|
394
|
+
: undefined;
|
|
395
|
+
return `${total} / ${context} tokens used${typeof percent === "number" ? ` (${percent}% full)` : ""}`;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function normalizeOptionDashes(text: string): string {
|
|
399
|
+
return text
|
|
400
|
+
.replace(/(^|\s)[\u2010-\u2015\u2212](?=\S)/g, "$1--")
|
|
401
|
+
.replace(/[\u2010-\u2015\u2212]/g, "-");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function parsePlanArgs(args: string): { mode: "off" } | { mode: "start"; prompt: string } {
|
|
405
|
+
const normalized = normalizeOptionDashes(args).trim();
|
|
406
|
+
if (!normalized) {
|
|
407
|
+
return { mode: "start", prompt: "" };
|
|
408
|
+
}
|
|
409
|
+
if (normalized === "off" || normalized === "--off") {
|
|
410
|
+
return { mode: "off" };
|
|
411
|
+
}
|
|
412
|
+
return { mode: "start", prompt: args.trim() };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseRenameArgs(args: string): { syncTopic: boolean; name: string } | null {
|
|
416
|
+
const tokens = normalizeOptionDashes(args)
|
|
417
|
+
.split(/\s+/)
|
|
418
|
+
.map((token) => token.trim())
|
|
419
|
+
.filter(Boolean);
|
|
420
|
+
let syncTopic = false;
|
|
421
|
+
const nameParts: string[] = [];
|
|
422
|
+
for (const token of tokens) {
|
|
423
|
+
if (token === "--sync") {
|
|
424
|
+
syncTopic = true;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
nameParts.push(token);
|
|
428
|
+
}
|
|
429
|
+
const name = nameParts.join(" ").trim();
|
|
430
|
+
if (!syncTopic && !name) {
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
return { syncTopic, name };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function buildResumeTopicName(params: { title?: string; projectKey?: string; threadId: string }): string | undefined {
|
|
437
|
+
const threadName = params.title?.trim() || params.threadId.trim();
|
|
438
|
+
if (!threadName) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
const projectName = path.basename(params.projectKey?.replace(/[\\/]+$/, "").trim() || "");
|
|
442
|
+
const normalizedThreadName = normalizeThreadTitleProjectSuffix(threadName, projectName);
|
|
443
|
+
return projectName ? `${normalizedThreadName} (${projectName})` : normalizedThreadName;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function buildThreadOnlyName(params: { title?: string; projectKey?: string; threadId: string }): string | undefined {
|
|
447
|
+
const threadName = params.title?.trim() || params.threadId.trim();
|
|
448
|
+
const projectName = path.basename(params.projectKey?.replace(/[\\/]+$/, "").trim() || "");
|
|
449
|
+
return normalizeThreadTitleProjectSuffix(threadName, projectName) || undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizeThreadTitleProjectSuffix(threadName: string, projectName?: string): string {
|
|
453
|
+
let normalized = threadName.trim();
|
|
454
|
+
if (!normalized) {
|
|
455
|
+
return normalized;
|
|
456
|
+
}
|
|
457
|
+
// Collapse duplicated trailing parenthetical groups from repeated sync renames.
|
|
458
|
+
normalized = normalized.replace(/(?: (\(([^()]+)\)))(?: \(\2\))+$/, " $1").trim();
|
|
459
|
+
if (projectName) {
|
|
460
|
+
const repeatedProjectSuffix = new RegExp(`(?: \\(${escapeRegExp(projectName)}\\))+$`);
|
|
461
|
+
normalized = normalized.replace(repeatedProjectSuffix, "").trim();
|
|
462
|
+
}
|
|
463
|
+
return normalized;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function escapeRegExp(value: string): string {
|
|
467
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function truncateDiscordLabel(text: string, maxChars = 80): string {
|
|
471
|
+
const trimmed = text.trim();
|
|
472
|
+
if (trimmed.length <= maxChars) {
|
|
473
|
+
return trimmed;
|
|
474
|
+
}
|
|
475
|
+
return `${trimmed.slice(0, Math.max(1, maxChars - 1)).trimEnd()}…`;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export class CodexPluginController {
|
|
479
|
+
private readonly settings;
|
|
480
|
+
private readonly client;
|
|
481
|
+
private readonly activeRuns = new Map<string, ActiveRunRecord>();
|
|
482
|
+
private readonly threadChangesCache = new Map<string, Promise<boolean | undefined>>();
|
|
483
|
+
private readonly store;
|
|
484
|
+
private serviceWorkspaceDir?: string;
|
|
485
|
+
private started = false;
|
|
486
|
+
|
|
487
|
+
constructor(private readonly api: OpenClawPluginApi) {
|
|
488
|
+
this.settings = resolvePluginSettings(this.api.pluginConfig);
|
|
489
|
+
this.client = new CodexAppServerClient(this.settings, this.api.logger);
|
|
490
|
+
this.store = new PluginStateStore(this.api.runtime.state.resolveStateDir());
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
createService(): OpenClawPluginService {
|
|
494
|
+
return {
|
|
495
|
+
id: `${PLUGIN_ID}-service`,
|
|
496
|
+
start: async (ctx) => {
|
|
497
|
+
this.serviceWorkspaceDir = ctx.workspaceDir;
|
|
498
|
+
await this.start();
|
|
499
|
+
},
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async start(): Promise<void> {
|
|
504
|
+
if (this.started) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
await this.store.load();
|
|
508
|
+
this.started = true;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async handleInboundClaim(event: {
|
|
512
|
+
content: string;
|
|
513
|
+
channel: string;
|
|
514
|
+
accountId?: string;
|
|
515
|
+
conversationId?: string;
|
|
516
|
+
parentConversationId?: string;
|
|
517
|
+
threadId?: string | number;
|
|
518
|
+
isGroup?: boolean;
|
|
519
|
+
metadata?: Record<string, unknown>;
|
|
520
|
+
}): Promise<{ handled: boolean }> {
|
|
521
|
+
try {
|
|
522
|
+
if (!this.settings.enabled) {
|
|
523
|
+
return { handled: false };
|
|
524
|
+
}
|
|
525
|
+
await this.start();
|
|
526
|
+
const conversation = toConversationTargetFromInbound(event);
|
|
527
|
+
if (!conversation) {
|
|
528
|
+
return { handled: false };
|
|
529
|
+
}
|
|
530
|
+
const activeKey = buildConversationKey(conversation);
|
|
531
|
+
const active = this.activeRuns.get(activeKey);
|
|
532
|
+
if (active) {
|
|
533
|
+
if (active.mode === "plan") {
|
|
534
|
+
this.api.logger.debug?.(
|
|
535
|
+
`codex inbound claim restarting active plan run conversation=${conversation.conversationId}`,
|
|
536
|
+
);
|
|
537
|
+
this.activeRuns.delete(activeKey);
|
|
538
|
+
await active.handle.interrupt().catch(() => undefined);
|
|
539
|
+
} else {
|
|
540
|
+
const pending = this.store.getPendingRequestByConversation(conversation);
|
|
541
|
+
if (pending?.state.questionnaire && !event.content.trim().startsWith("/")) {
|
|
542
|
+
const handled = await this.handlePendingQuestionnaireFreeformAnswer(
|
|
543
|
+
conversation,
|
|
544
|
+
pending,
|
|
545
|
+
active.handle,
|
|
546
|
+
event.content,
|
|
547
|
+
);
|
|
548
|
+
if (handled) {
|
|
549
|
+
return { handled: true };
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
const handled = await active.handle.queueMessage(event.content);
|
|
554
|
+
if (handled) {
|
|
555
|
+
return { handled: true };
|
|
556
|
+
}
|
|
557
|
+
this.api.logger.warn(
|
|
558
|
+
`codex inbound claim could not enqueue message for active run; restarting thread conversation=${conversation.conversationId}`,
|
|
559
|
+
);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
this.api.logger.warn(
|
|
562
|
+
`codex inbound claim active run enqueue failed; restarting thread conversation=${conversation.conversationId}: ${String(error)}`,
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
this.activeRuns.delete(activeKey);
|
|
566
|
+
await active.handle.interrupt().catch(() => undefined);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const resolvedBinding =
|
|
570
|
+
this.store.getBinding(conversation) ??
|
|
571
|
+
(await this.hydrateApprovedBinding(conversation));
|
|
572
|
+
this.api.logger.debug?.(
|
|
573
|
+
`codex inbound claim channel=${conversation.channel} account=${conversation.accountId} conversation=${conversation.conversationId} parent=${conversation.parentConversationId ?? "<none>"} local=${resolvedBinding ? "yes" : "no"}`,
|
|
574
|
+
);
|
|
575
|
+
if (!resolvedBinding) {
|
|
576
|
+
return { handled: false };
|
|
577
|
+
}
|
|
578
|
+
await this.startTurn({
|
|
579
|
+
conversation,
|
|
580
|
+
binding: resolvedBinding,
|
|
581
|
+
workspaceDir: resolvedBinding.workspaceDir,
|
|
582
|
+
prompt: event.content,
|
|
583
|
+
reason: "inbound",
|
|
584
|
+
});
|
|
585
|
+
return { handled: true };
|
|
586
|
+
} catch (error) {
|
|
587
|
+
const detail =
|
|
588
|
+
error instanceof Error ? `${error.message}\n${error.stack ?? ""}`.trim() : String(error);
|
|
589
|
+
this.api.logger.error(`codex inbound claim failed: ${detail}`);
|
|
590
|
+
throw error;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async handleTelegramInteractive(ctx: PluginInteractiveTelegramHandlerContext): Promise<void> {
|
|
595
|
+
await this.start();
|
|
596
|
+
const bindingApi = asScopedBindingApi(ctx);
|
|
597
|
+
const callback = this.store.getCallback(ctx.callback.payload);
|
|
598
|
+
if (!callback) {
|
|
599
|
+
await ctx.respond.reply({ text: "That Codex action expired. Please retry the command." });
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
await this.dispatchCallbackAction(callback, {
|
|
603
|
+
conversation: {
|
|
604
|
+
channel: "telegram",
|
|
605
|
+
accountId: ctx.accountId,
|
|
606
|
+
conversationId: ctx.conversationId,
|
|
607
|
+
parentConversationId: ctx.parentConversationId,
|
|
608
|
+
threadId: ctx.threadId,
|
|
609
|
+
},
|
|
610
|
+
clear: async () => {
|
|
611
|
+
await ctx.respond.clearButtons().catch(() => undefined);
|
|
612
|
+
},
|
|
613
|
+
reply: async (text) => {
|
|
614
|
+
await ctx.respond.reply({ text });
|
|
615
|
+
},
|
|
616
|
+
editPicker: async (picker) => {
|
|
617
|
+
await ctx.respond.editMessage({
|
|
618
|
+
text: picker.text,
|
|
619
|
+
buttons: picker.buttons,
|
|
620
|
+
});
|
|
621
|
+
},
|
|
622
|
+
requestConversationBinding: async (params) => {
|
|
623
|
+
const requestConversationBinding = bindingApi.requestConversationBinding;
|
|
624
|
+
if (!requestConversationBinding) {
|
|
625
|
+
return { status: "error", message: "Conversation binding is unavailable." } as const;
|
|
626
|
+
}
|
|
627
|
+
const result = await requestConversationBinding(params);
|
|
628
|
+
if (result.status === "pending") {
|
|
629
|
+
const buttons = asRecord(result.reply.channelData?.telegram)?.buttons as
|
|
630
|
+
| PluginInteractiveButtons
|
|
631
|
+
| undefined;
|
|
632
|
+
await ctx.respond.reply({
|
|
633
|
+
text: result.reply.text ?? "Bind approval requested.",
|
|
634
|
+
buttons,
|
|
635
|
+
});
|
|
636
|
+
return { status: "pending", reply: result.reply } as const;
|
|
637
|
+
}
|
|
638
|
+
return result;
|
|
639
|
+
},
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async handleDiscordInteractive(ctx: PluginInteractiveDiscordHandlerContext): Promise<void> {
|
|
644
|
+
await this.start();
|
|
645
|
+
const bindingApi = asScopedBindingApi(ctx);
|
|
646
|
+
const callback = this.store.getCallback(ctx.interaction.payload);
|
|
647
|
+
if (!callback) {
|
|
648
|
+
await ctx.respond.reply({ text: "That Codex action expired. Please retry the command.", ephemeral: true });
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const callbackConversationId =
|
|
652
|
+
callback.conversation.channel === "discord"
|
|
653
|
+
? normalizeDiscordConversationId(callback.conversation.conversationId)
|
|
654
|
+
: undefined;
|
|
655
|
+
const conversationId =
|
|
656
|
+
callbackConversationId ??
|
|
657
|
+
normalizeDiscordInteractiveConversationId({
|
|
658
|
+
conversationId: ctx.conversationId,
|
|
659
|
+
guildId: ctx.guildId,
|
|
660
|
+
});
|
|
661
|
+
if (!conversationId) {
|
|
662
|
+
await ctx.respond.reply({
|
|
663
|
+
text: "I couldn’t determine the Discord conversation for that action. Please retry the command.",
|
|
664
|
+
ephemeral: true,
|
|
665
|
+
});
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const conversation: ConversationTarget = {
|
|
669
|
+
channel: "discord",
|
|
670
|
+
accountId: callback.conversation.accountId ?? ctx.accountId,
|
|
671
|
+
conversationId,
|
|
672
|
+
parentConversationId: callback.conversation.parentConversationId ?? ctx.parentConversationId,
|
|
673
|
+
};
|
|
674
|
+
try {
|
|
675
|
+
await this.dispatchCallbackAction(callback, {
|
|
676
|
+
conversation,
|
|
677
|
+
clear: async () => {
|
|
678
|
+
try {
|
|
679
|
+
await ctx.respond.clearComponents();
|
|
680
|
+
} catch {
|
|
681
|
+
await ctx.respond.acknowledge().catch(() => undefined);
|
|
682
|
+
}
|
|
683
|
+
},
|
|
684
|
+
reply: async (text) => {
|
|
685
|
+
await ctx.respond.reply({ text, ephemeral: true });
|
|
686
|
+
},
|
|
687
|
+
editPicker: async (picker) => {
|
|
688
|
+
this.api.logger.debug(
|
|
689
|
+
`codex discord picker refresh conversation=${conversationId} rows=${picker.buttons?.length ?? 0}`,
|
|
690
|
+
);
|
|
691
|
+
await ctx.respond.clearComponents({ text: picker.text }).catch((error) => {
|
|
692
|
+
const detail = String(error);
|
|
693
|
+
if (detail.includes("already been acknowledged")) {
|
|
694
|
+
this.api.logger.debug?.(
|
|
695
|
+
`codex discord picker clear skipped conversation=${conversationId}: ${detail}`,
|
|
696
|
+
);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
this.api.logger.warn(
|
|
700
|
+
`codex discord picker clear failed conversation=${conversationId}: ${detail}`,
|
|
701
|
+
);
|
|
702
|
+
});
|
|
703
|
+
await this.sendDiscordPicker(conversation, picker);
|
|
704
|
+
},
|
|
705
|
+
requestConversationBinding: async (params) => {
|
|
706
|
+
const requestConversationBinding = bindingApi.requestConversationBinding;
|
|
707
|
+
if (!requestConversationBinding) {
|
|
708
|
+
return { status: "error", message: "Conversation binding is unavailable." } as const;
|
|
709
|
+
}
|
|
710
|
+
const result = await requestConversationBinding(params);
|
|
711
|
+
if (result.status === "pending") {
|
|
712
|
+
const telegramData = asRecord(result.reply.channelData?.telegram);
|
|
713
|
+
const buttons = Array.isArray(telegramData?.buttons)
|
|
714
|
+
? (telegramData.buttons as PluginInteractiveButtons)
|
|
715
|
+
: undefined;
|
|
716
|
+
await this.sendDiscordPicker(conversation, {
|
|
717
|
+
text: result.reply.text ?? "Bind approval requested.",
|
|
718
|
+
buttons,
|
|
719
|
+
});
|
|
720
|
+
return { status: "pending", reply: result.reply } as const;
|
|
721
|
+
}
|
|
722
|
+
return result;
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
} catch (error) {
|
|
726
|
+
const detail = error instanceof Error ? error.stack ?? error.message : String(error);
|
|
727
|
+
this.api.logger.warn(`codex discord interactive failed conversation=${conversationId}: ${detail}`);
|
|
728
|
+
await ctx.respond
|
|
729
|
+
.reply({
|
|
730
|
+
text: "Codex hit an error handling that action. Please retry the command.",
|
|
731
|
+
ephemeral: true,
|
|
732
|
+
})
|
|
733
|
+
.catch(() => undefined);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async handleCommand(commandName: string, ctx: PluginCommandContext): Promise<ReplyPayload> {
|
|
738
|
+
await this.start();
|
|
739
|
+
const bindingApi = asScopedBindingApi(ctx);
|
|
740
|
+
const conversation = toConversationTargetFromCommand(ctx);
|
|
741
|
+
const currentBinding =
|
|
742
|
+
conversation && bindingApi.getCurrentConversationBinding
|
|
743
|
+
? await bindingApi.getCurrentConversationBinding()
|
|
744
|
+
: null;
|
|
745
|
+
const binding =
|
|
746
|
+
conversation && currentBinding
|
|
747
|
+
? this.store.getBinding(conversation) ??
|
|
748
|
+
(await this.hydrateApprovedBinding(conversation))
|
|
749
|
+
: null;
|
|
750
|
+
const args = ctx.args?.trim() ?? "";
|
|
751
|
+
if (isDiscordChannel(ctx.channel)) {
|
|
752
|
+
this.api.logger.debug(
|
|
753
|
+
`codex discord command /${commandName} from=${ctx.from ?? "<none>"} to=${ctx.to ?? "<none>"} conversation=${conversation?.conversationId ?? "<none>"}`,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
switch (commandName) {
|
|
758
|
+
case "codex_resume":
|
|
759
|
+
return await this.handleJoinCommand(conversation, binding, args, ctx.channel, ctx);
|
|
760
|
+
case "codex_detach":
|
|
761
|
+
if (!conversation) {
|
|
762
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
763
|
+
}
|
|
764
|
+
const detachResult = await bindingApi.detachConversationBinding?.();
|
|
765
|
+
await this.unbindConversation(conversation);
|
|
766
|
+
return {
|
|
767
|
+
text: detachResult?.removed
|
|
768
|
+
? "Detached this conversation from Codex."
|
|
769
|
+
: "This conversation is not currently bound to Codex.",
|
|
770
|
+
};
|
|
771
|
+
case "codex_status":
|
|
772
|
+
return await this.handleStatusCommand(
|
|
773
|
+
conversation,
|
|
774
|
+
binding,
|
|
775
|
+
Boolean(currentBinding || binding),
|
|
776
|
+
);
|
|
777
|
+
case "codex_stop":
|
|
778
|
+
return await this.handleStopCommand(conversation);
|
|
779
|
+
case "codex_steer":
|
|
780
|
+
return await this.handleSteerCommand(conversation, args);
|
|
781
|
+
case "codex_plan":
|
|
782
|
+
return await this.handlePlanCommand(conversation, binding, args);
|
|
783
|
+
case "codex_review":
|
|
784
|
+
return await this.handleReviewCommand(conversation, binding, args);
|
|
785
|
+
case "codex_compact":
|
|
786
|
+
return await this.handleCompactCommand(conversation, binding);
|
|
787
|
+
case "codex_skills":
|
|
788
|
+
return await this.handleSkillsCommand(conversation, binding, args);
|
|
789
|
+
case "codex_experimental":
|
|
790
|
+
return await this.handleExperimentalCommand(binding);
|
|
791
|
+
case "codex_mcp":
|
|
792
|
+
return await this.handleMcpCommand(binding, args);
|
|
793
|
+
case "codex_fast":
|
|
794
|
+
return await this.handleFastCommand(binding, args);
|
|
795
|
+
case "codex_model":
|
|
796
|
+
return await this.handleModelCommand(conversation, binding, args);
|
|
797
|
+
case "codex_permissions":
|
|
798
|
+
return await this.handlePermissionsCommand(binding);
|
|
799
|
+
case "codex_init":
|
|
800
|
+
return await this.handlePromptAlias(conversation, binding, args, "/init");
|
|
801
|
+
case "codex_diff":
|
|
802
|
+
return await this.handlePromptAlias(conversation, binding, args, "/diff");
|
|
803
|
+
case "codex_rename":
|
|
804
|
+
return await this.handleRenameCommand(conversation, binding, args);
|
|
805
|
+
default:
|
|
806
|
+
return { text: "Unknown Codex command." };
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private async handleListCommand(
|
|
811
|
+
conversation: ConversationTarget | null,
|
|
812
|
+
binding: StoredBinding | null,
|
|
813
|
+
filter: string,
|
|
814
|
+
channel: string,
|
|
815
|
+
): Promise<ReplyPayload> {
|
|
816
|
+
const parsed = parseThreadSelectionArgs(filter);
|
|
817
|
+
if (!conversation) {
|
|
818
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
819
|
+
}
|
|
820
|
+
const picker = parsed.listProjects
|
|
821
|
+
? await this.renderProjectPicker(conversation, binding, parsed, 0)
|
|
822
|
+
: await this.renderThreadPicker(conversation, binding, parsed, 0);
|
|
823
|
+
if (isDiscordChannel(channel) && picker.buttons) {
|
|
824
|
+
try {
|
|
825
|
+
await this.sendDiscordPicker(conversation, picker);
|
|
826
|
+
return { text: "Sent a Codex thread picker to this Discord conversation." };
|
|
827
|
+
} catch (error) {
|
|
828
|
+
this.api.logger.warn(`codex discord picker send failed: ${String(error)}`);
|
|
829
|
+
return { text: picker.text };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return buildReplyWithButtons(picker.text, picker.buttons);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private async handleJoinCommand(
|
|
836
|
+
conversation: ConversationTarget | null,
|
|
837
|
+
binding: StoredBinding | null,
|
|
838
|
+
args: string,
|
|
839
|
+
channel: string,
|
|
840
|
+
ctx: PluginCommandContext,
|
|
841
|
+
): Promise<ReplyPayload> {
|
|
842
|
+
const bindingApi = asScopedBindingApi(ctx);
|
|
843
|
+
if (!conversation) {
|
|
844
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
845
|
+
}
|
|
846
|
+
const parsed = parseThreadSelectionArgs(args);
|
|
847
|
+
if (parsed.listProjects || !parsed.query) {
|
|
848
|
+
const passthroughArgs = [
|
|
849
|
+
parsed.includeAll ? "--all" : "",
|
|
850
|
+
parsed.listProjects ? "--projects" : "",
|
|
851
|
+
parsed.syncTopic ? "--sync" : "",
|
|
852
|
+
parsed.cwd ? `--cwd ${parsed.cwd}` : "",
|
|
853
|
+
]
|
|
854
|
+
.filter(Boolean)
|
|
855
|
+
.join(" ");
|
|
856
|
+
return await this.handleListCommand(conversation, binding, passthroughArgs, channel);
|
|
857
|
+
}
|
|
858
|
+
const workspaceDir = this.resolveThreadWorkspaceDir(parsed, binding, false);
|
|
859
|
+
const selection = await this.resolveSingleThread(
|
|
860
|
+
binding?.sessionKey,
|
|
861
|
+
workspaceDir,
|
|
862
|
+
parsed.query,
|
|
863
|
+
);
|
|
864
|
+
if (selection.kind === "none") {
|
|
865
|
+
return { text: `No Codex thread matched "${parsed.query}".` };
|
|
866
|
+
}
|
|
867
|
+
if (selection.kind === "ambiguous") {
|
|
868
|
+
const picker = await this.renderThreadPicker(conversation, binding, parsed, 0);
|
|
869
|
+
if (isDiscordChannel(channel) && picker.buttons) {
|
|
870
|
+
try {
|
|
871
|
+
await this.sendDiscordPicker(conversation, picker);
|
|
872
|
+
return {
|
|
873
|
+
text: `Multiple Codex threads matched "${parsed.query}". Sent a picker to this Discord conversation.`,
|
|
874
|
+
};
|
|
875
|
+
} catch (error) {
|
|
876
|
+
this.api.logger.warn(`codex discord picker send failed: ${String(error)}`);
|
|
877
|
+
return { text: picker.text };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
return buildReplyWithButtons(picker.text, picker.buttons);
|
|
881
|
+
}
|
|
882
|
+
const bindResult = await this.requestConversationBinding(conversation, {
|
|
883
|
+
threadId: selection.thread.threadId,
|
|
884
|
+
workspaceDir:
|
|
885
|
+
selection.thread.projectKey ||
|
|
886
|
+
workspaceDir ||
|
|
887
|
+
resolveWorkspaceDir({
|
|
888
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
889
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
890
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
891
|
+
}),
|
|
892
|
+
threadTitle: selection.thread.title,
|
|
893
|
+
}, bindingApi.requestConversationBinding);
|
|
894
|
+
if (bindResult.status === "pending") {
|
|
895
|
+
return bindResult.reply;
|
|
896
|
+
}
|
|
897
|
+
if (bindResult.status === "error") {
|
|
898
|
+
return { text: bindResult.message };
|
|
899
|
+
}
|
|
900
|
+
if (parsed.syncTopic) {
|
|
901
|
+
const syncedName = buildResumeTopicName({
|
|
902
|
+
title: selection.thread.title,
|
|
903
|
+
projectKey: selection.thread.projectKey,
|
|
904
|
+
threadId: selection.thread.threadId,
|
|
905
|
+
});
|
|
906
|
+
if (syncedName) {
|
|
907
|
+
await this.renameConversationIfSupported(conversation, syncedName);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
const summary = await this.buildBoundConversationSummaryReply(conversation);
|
|
911
|
+
this.queueFollowUpTexts(conversation, summary.followUps);
|
|
912
|
+
return summary.initialReply;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private async handleStatusCommand(
|
|
916
|
+
conversation: ConversationTarget | null,
|
|
917
|
+
binding: StoredBinding | null,
|
|
918
|
+
bindingActive: boolean,
|
|
919
|
+
): Promise<ReplyPayload> {
|
|
920
|
+
return {
|
|
921
|
+
text: await this.buildStatusText(conversation, binding, bindingActive),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async handleStopCommand(conversation: ConversationTarget | null): Promise<ReplyPayload> {
|
|
926
|
+
if (!conversation) {
|
|
927
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
928
|
+
}
|
|
929
|
+
const active = this.activeRuns.get(buildConversationKey(conversation));
|
|
930
|
+
if (!active) {
|
|
931
|
+
return { text: "No active Codex run to stop." };
|
|
932
|
+
}
|
|
933
|
+
await active.handle.interrupt();
|
|
934
|
+
return { text: "Stopping Codex now." };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
private async handleSteerCommand(
|
|
938
|
+
conversation: ConversationTarget | null,
|
|
939
|
+
args: string,
|
|
940
|
+
): Promise<ReplyPayload> {
|
|
941
|
+
if (!conversation) {
|
|
942
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
943
|
+
}
|
|
944
|
+
const prompt = args.trim();
|
|
945
|
+
if (!prompt) {
|
|
946
|
+
return { text: "Usage: /codex_steer <message>" };
|
|
947
|
+
}
|
|
948
|
+
const active = this.activeRuns.get(buildConversationKey(conversation));
|
|
949
|
+
if (!active) {
|
|
950
|
+
return { text: "No active Codex run to steer." };
|
|
951
|
+
}
|
|
952
|
+
const handled = await active.handle.queueMessage(prompt);
|
|
953
|
+
return { text: handled ? "Sent steer message to Codex." : "Codex is not accepting steer input right now." };
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private async handlePlanCommand(
|
|
957
|
+
conversation: ConversationTarget | null,
|
|
958
|
+
binding: StoredBinding | null,
|
|
959
|
+
args: string,
|
|
960
|
+
): Promise<ReplyPayload> {
|
|
961
|
+
if (!conversation) {
|
|
962
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
963
|
+
}
|
|
964
|
+
const parsed = parsePlanArgs(args);
|
|
965
|
+
if (parsed.mode === "off") {
|
|
966
|
+
const key = buildConversationKey(conversation);
|
|
967
|
+
const active = this.activeRuns.get(key);
|
|
968
|
+
if (active?.mode === "plan") {
|
|
969
|
+
this.activeRuns.delete(key);
|
|
970
|
+
await active.handle.interrupt().catch(() => undefined);
|
|
971
|
+
}
|
|
972
|
+
return { text: "Exited Codex plan mode. Future turns will use default coding mode." };
|
|
973
|
+
}
|
|
974
|
+
const prompt = parsed.prompt.trim();
|
|
975
|
+
if (!prompt) {
|
|
976
|
+
return { text: "Usage: /codex_plan <goal> or /codex_plan off" };
|
|
977
|
+
}
|
|
978
|
+
const workspaceDir = resolveWorkspaceDir({
|
|
979
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
980
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
981
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
982
|
+
});
|
|
983
|
+
await this.startPlan({
|
|
984
|
+
conversation,
|
|
985
|
+
binding,
|
|
986
|
+
workspaceDir,
|
|
987
|
+
prompt,
|
|
988
|
+
announceStart: false,
|
|
989
|
+
});
|
|
990
|
+
return buildPlainReply(
|
|
991
|
+
"Starting Codex plan mode. I’ll relay the questions and final plan as they arrive.",
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private async handleReviewCommand(
|
|
996
|
+
conversation: ConversationTarget | null,
|
|
997
|
+
binding: StoredBinding | null,
|
|
998
|
+
args: string,
|
|
999
|
+
): Promise<ReplyPayload> {
|
|
1000
|
+
if (!conversation || !binding) {
|
|
1001
|
+
return { text: "Bind this conversation to a Codex thread before running review." };
|
|
1002
|
+
}
|
|
1003
|
+
const workspaceDir = binding.workspaceDir;
|
|
1004
|
+
await this.startReview({
|
|
1005
|
+
conversation,
|
|
1006
|
+
binding,
|
|
1007
|
+
workspaceDir,
|
|
1008
|
+
target: args.trim()
|
|
1009
|
+
? { type: "custom", instructions: args.trim() }
|
|
1010
|
+
: { type: "uncommittedChanges" },
|
|
1011
|
+
announceStart: false,
|
|
1012
|
+
});
|
|
1013
|
+
return buildPlainReply(
|
|
1014
|
+
args.trim()
|
|
1015
|
+
? "Starting Codex review with your custom focus. I’ll send the findings when the review finishes."
|
|
1016
|
+
: "Starting Codex review of the current changes. I’ll send the findings when the review finishes.",
|
|
1017
|
+
);
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
private async handleCompactCommand(
|
|
1021
|
+
conversation: ConversationTarget | null,
|
|
1022
|
+
binding: StoredBinding | null,
|
|
1023
|
+
): Promise<ReplyPayload> {
|
|
1024
|
+
if (!conversation || !binding) {
|
|
1025
|
+
return { text: "Bind this conversation to a Codex thread before compacting it." };
|
|
1026
|
+
}
|
|
1027
|
+
void this.startCompact({
|
|
1028
|
+
conversation,
|
|
1029
|
+
binding,
|
|
1030
|
+
});
|
|
1031
|
+
return buildPlainReply(this.buildCompactStartText(binding.contextUsage));
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
private async startCompact(params: {
|
|
1035
|
+
conversation: ConversationTarget;
|
|
1036
|
+
binding: StoredBinding;
|
|
1037
|
+
}): Promise<void> {
|
|
1038
|
+
const { conversation, binding } = params;
|
|
1039
|
+
const typing = await this.startTypingLease(conversation);
|
|
1040
|
+
let startingUsage = binding.contextUsage;
|
|
1041
|
+
let latestUsage = startingUsage;
|
|
1042
|
+
let lastEmittedUsageText = binding.contextUsage ? formatContextUsageText(binding.contextUsage) : undefined;
|
|
1043
|
+
try {
|
|
1044
|
+
let keepaliveInterval: NodeJS.Timeout | undefined;
|
|
1045
|
+
const progressTimer = setTimeout(() => {
|
|
1046
|
+
void (async () => {
|
|
1047
|
+
const usageText =
|
|
1048
|
+
latestUsage ? formatContextUsageText(latestUsage) : undefined;
|
|
1049
|
+
if (usageText && usageText !== lastEmittedUsageText) {
|
|
1050
|
+
lastEmittedUsageText = usageText;
|
|
1051
|
+
}
|
|
1052
|
+
await this.sendText(
|
|
1053
|
+
conversation,
|
|
1054
|
+
usageText
|
|
1055
|
+
? `Codex is still compacting.\nLatest context usage: ${usageText}`
|
|
1056
|
+
: "Codex is still compacting.",
|
|
1057
|
+
);
|
|
1058
|
+
})();
|
|
1059
|
+
keepaliveInterval = setInterval(() => {
|
|
1060
|
+
void this.sendText(conversation, "Codex is still compacting.");
|
|
1061
|
+
}, COMPACT_PROGRESS_INTERVAL_MS);
|
|
1062
|
+
}, COMPACT_PROGRESS_DELAY_MS);
|
|
1063
|
+
const result = await this.client.compactThread({
|
|
1064
|
+
sessionKey: binding.sessionKey,
|
|
1065
|
+
threadId: binding.threadId,
|
|
1066
|
+
onProgress: async (progress) => {
|
|
1067
|
+
if (progress.usage) {
|
|
1068
|
+
latestUsage = progress.usage;
|
|
1069
|
+
startingUsage ??= progress.usage;
|
|
1070
|
+
}
|
|
1071
|
+
if (progress.phase === "started") {
|
|
1072
|
+
await this.sendText(conversation, "Codex compaction started.");
|
|
1073
|
+
}
|
|
1074
|
+
},
|
|
1075
|
+
});
|
|
1076
|
+
clearTimeout(progressTimer);
|
|
1077
|
+
if (keepaliveInterval) {
|
|
1078
|
+
clearInterval(keepaliveInterval);
|
|
1079
|
+
}
|
|
1080
|
+
await this.sendText(
|
|
1081
|
+
conversation,
|
|
1082
|
+
[
|
|
1083
|
+
"Codex compaction completed.",
|
|
1084
|
+
startingUsage ? `Starting context usage: ${formatContextUsageText(startingUsage)}` : "",
|
|
1085
|
+
result.usage ? `Final context usage: ${formatContextUsageText(result.usage)}` : "",
|
|
1086
|
+
result.usage?.remainingPercent != null
|
|
1087
|
+
? `Context remaining: ${result.usage.remainingPercent}%.`
|
|
1088
|
+
: "",
|
|
1089
|
+
]
|
|
1090
|
+
.filter(Boolean)
|
|
1091
|
+
.join("\n"),
|
|
1092
|
+
);
|
|
1093
|
+
if (result.usage) {
|
|
1094
|
+
await this.store.upsertBinding({
|
|
1095
|
+
...binding,
|
|
1096
|
+
contextUsage: result.usage,
|
|
1097
|
+
updatedAt: Date.now(),
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
return;
|
|
1101
|
+
} catch (error) {
|
|
1102
|
+
await this.sendText(conversation, formatFailureText("compact", error));
|
|
1103
|
+
} finally {
|
|
1104
|
+
typing?.stop();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
private async handleSkillsCommand(
|
|
1109
|
+
conversation: ConversationTarget | null,
|
|
1110
|
+
binding: StoredBinding | null,
|
|
1111
|
+
args: string,
|
|
1112
|
+
): Promise<ReplyPayload> {
|
|
1113
|
+
const workspaceDir = resolveWorkspaceDir({
|
|
1114
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
1115
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
1116
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
1117
|
+
});
|
|
1118
|
+
const skills = await this.client.listSkills({
|
|
1119
|
+
sessionKey: binding?.sessionKey,
|
|
1120
|
+
workspaceDir,
|
|
1121
|
+
});
|
|
1122
|
+
const text = formatSkills({
|
|
1123
|
+
workspaceDir,
|
|
1124
|
+
skills,
|
|
1125
|
+
filter: args,
|
|
1126
|
+
});
|
|
1127
|
+
if (!conversation) {
|
|
1128
|
+
return { text };
|
|
1129
|
+
}
|
|
1130
|
+
const filtered = args.trim()
|
|
1131
|
+
? skills.filter((skill) => {
|
|
1132
|
+
const haystack = [skill.name, skill.description, skill.cwd].filter(Boolean).join("\n");
|
|
1133
|
+
return haystack.toLowerCase().includes(args.trim().toLowerCase());
|
|
1134
|
+
})
|
|
1135
|
+
: skills;
|
|
1136
|
+
const buttons: PluginInteractiveButtons = [];
|
|
1137
|
+
for (const skill of filtered.slice(0, 8)) {
|
|
1138
|
+
const callback = await this.store.putCallback({
|
|
1139
|
+
kind: "run-prompt",
|
|
1140
|
+
conversation,
|
|
1141
|
+
prompt: `$${skill.name}`,
|
|
1142
|
+
workspaceDir: binding?.workspaceDir || workspaceDir,
|
|
1143
|
+
});
|
|
1144
|
+
buttons.push([
|
|
1145
|
+
{
|
|
1146
|
+
text: `$${skill.name}`,
|
|
1147
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
1148
|
+
},
|
|
1149
|
+
]);
|
|
1150
|
+
}
|
|
1151
|
+
if (conversation && isDiscordChannel(conversation.channel) && buttons.length > 0) {
|
|
1152
|
+
try {
|
|
1153
|
+
await this.sendReply(conversation, {
|
|
1154
|
+
text,
|
|
1155
|
+
buttons,
|
|
1156
|
+
});
|
|
1157
|
+
return { text: "Sent Codex skills to this Discord conversation." };
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
this.api.logger.warn(`codex discord skills send failed: ${String(error)}`);
|
|
1160
|
+
return { text };
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
return buildReplyWithButtons(text, buttons.length > 0 ? buttons : undefined);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
private async handleExperimentalCommand(binding: StoredBinding | null): Promise<ReplyPayload> {
|
|
1167
|
+
const features = await this.client.listExperimentalFeatures({
|
|
1168
|
+
sessionKey: binding?.sessionKey,
|
|
1169
|
+
});
|
|
1170
|
+
return { text: formatExperimentalFeatures(features) };
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private async handleMcpCommand(binding: StoredBinding | null, args: string): Promise<ReplyPayload> {
|
|
1174
|
+
const servers = await this.client.listMcpServers({
|
|
1175
|
+
sessionKey: binding?.sessionKey,
|
|
1176
|
+
});
|
|
1177
|
+
return {
|
|
1178
|
+
text: formatMcpServers({
|
|
1179
|
+
servers,
|
|
1180
|
+
filter: args,
|
|
1181
|
+
}),
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
private async handleFastCommand(binding: StoredBinding | null, args: string): Promise<ReplyPayload> {
|
|
1186
|
+
if (!binding) {
|
|
1187
|
+
return { text: "Bind this conversation to a Codex thread before toggling fast mode." };
|
|
1188
|
+
}
|
|
1189
|
+
const action = parseFastAction(args);
|
|
1190
|
+
if (typeof action === "object") {
|
|
1191
|
+
return { text: action.error };
|
|
1192
|
+
}
|
|
1193
|
+
const state = await this.client.readThreadState({
|
|
1194
|
+
sessionKey: binding.sessionKey,
|
|
1195
|
+
threadId: binding.threadId,
|
|
1196
|
+
});
|
|
1197
|
+
const currentTier = normalizeServiceTier(state.serviceTier);
|
|
1198
|
+
if (action === "status") {
|
|
1199
|
+
return { text: `Fast mode is ${formatFastModeValue(currentTier)}.` };
|
|
1200
|
+
}
|
|
1201
|
+
const nextTier =
|
|
1202
|
+
action === "toggle" ? (currentTier === "fast" || currentTier === "priority" ? null : "fast")
|
|
1203
|
+
: action === "on" ? "fast"
|
|
1204
|
+
: null;
|
|
1205
|
+
const updated = await this.client.setThreadServiceTier({
|
|
1206
|
+
sessionKey: binding.sessionKey,
|
|
1207
|
+
threadId: binding.threadId,
|
|
1208
|
+
serviceTier: nextTier,
|
|
1209
|
+
});
|
|
1210
|
+
return {
|
|
1211
|
+
text: `Fast mode set to ${formatFastModeValue(updated.serviceTier)}.`,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private async handleModelCommand(
|
|
1216
|
+
conversation: ConversationTarget | null,
|
|
1217
|
+
binding: StoredBinding | null,
|
|
1218
|
+
args: string,
|
|
1219
|
+
): Promise<ReplyPayload> {
|
|
1220
|
+
if (!binding) {
|
|
1221
|
+
const models = await this.client.listModels({});
|
|
1222
|
+
return { text: formatModels(models) };
|
|
1223
|
+
}
|
|
1224
|
+
if (!args.trim()) {
|
|
1225
|
+
const [models, state] = await Promise.all([
|
|
1226
|
+
this.client.listModels({ sessionKey: binding.sessionKey }),
|
|
1227
|
+
this.client.readThreadState({
|
|
1228
|
+
sessionKey: binding.sessionKey,
|
|
1229
|
+
threadId: binding.threadId,
|
|
1230
|
+
}),
|
|
1231
|
+
]);
|
|
1232
|
+
if (!conversation) {
|
|
1233
|
+
return { text: formatModels(models, state) };
|
|
1234
|
+
}
|
|
1235
|
+
const buttons: PluginInteractiveButtons = [];
|
|
1236
|
+
for (const model of models.slice(0, 8)) {
|
|
1237
|
+
const callback = await this.store.putCallback({
|
|
1238
|
+
kind: "set-model",
|
|
1239
|
+
conversation,
|
|
1240
|
+
model: model.id,
|
|
1241
|
+
});
|
|
1242
|
+
buttons.push([
|
|
1243
|
+
{
|
|
1244
|
+
text: `${model.id}${model.current || model.id === state.model ? " (current)" : ""}`,
|
|
1245
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
1246
|
+
},
|
|
1247
|
+
]);
|
|
1248
|
+
}
|
|
1249
|
+
if (isDiscordChannel(conversation.channel) && buttons.length > 0) {
|
|
1250
|
+
try {
|
|
1251
|
+
await this.sendReply(conversation, {
|
|
1252
|
+
text: formatModels(models, state),
|
|
1253
|
+
buttons,
|
|
1254
|
+
});
|
|
1255
|
+
return { text: "Sent Codex model choices to this Discord conversation." };
|
|
1256
|
+
} catch (error) {
|
|
1257
|
+
this.api.logger.warn(`codex discord model picker send failed: ${String(error)}`);
|
|
1258
|
+
return { text: formatModels(models, state) };
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
return buildReplyWithButtons(formatModels(models, state), buttons);
|
|
1262
|
+
}
|
|
1263
|
+
const state = await this.client.setThreadModel({
|
|
1264
|
+
sessionKey: binding.sessionKey,
|
|
1265
|
+
threadId: binding.threadId,
|
|
1266
|
+
model: args.trim(),
|
|
1267
|
+
workspaceDir: binding.workspaceDir,
|
|
1268
|
+
});
|
|
1269
|
+
return { text: `Codex model set to ${state.model || args.trim()}.` };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
private async handlePermissionsCommand(binding: StoredBinding | null): Promise<ReplyPayload> {
|
|
1273
|
+
if (!binding) {
|
|
1274
|
+
const [account, limits] = await Promise.all([
|
|
1275
|
+
this.client.readAccount({}),
|
|
1276
|
+
this.client.readRateLimits({}),
|
|
1277
|
+
]);
|
|
1278
|
+
return { text: formatAccountSummary(account, limits) };
|
|
1279
|
+
}
|
|
1280
|
+
const [state, account, limits] = await Promise.all([
|
|
1281
|
+
this.client.readThreadState({
|
|
1282
|
+
sessionKey: binding.sessionKey,
|
|
1283
|
+
threadId: binding.threadId,
|
|
1284
|
+
}),
|
|
1285
|
+
this.client.readAccount({ sessionKey: binding.sessionKey }),
|
|
1286
|
+
this.client.readRateLimits({ sessionKey: binding.sessionKey }),
|
|
1287
|
+
]);
|
|
1288
|
+
return {
|
|
1289
|
+
text:
|
|
1290
|
+
`${formatThreadState(state, binding)}\n\n${formatAccountSummary(account, limits)}`.trim(),
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
private async handlePromptAlias(
|
|
1295
|
+
conversation: ConversationTarget | null,
|
|
1296
|
+
binding: StoredBinding | null,
|
|
1297
|
+
args: string,
|
|
1298
|
+
alias: string,
|
|
1299
|
+
): Promise<ReplyPayload> {
|
|
1300
|
+
if (!conversation) {
|
|
1301
|
+
return { text: "This command needs a Telegram or Discord conversation." };
|
|
1302
|
+
}
|
|
1303
|
+
const workspaceDir = resolveWorkspaceDir({
|
|
1304
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
1305
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
1306
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
1307
|
+
});
|
|
1308
|
+
await this.startTurn({
|
|
1309
|
+
conversation,
|
|
1310
|
+
binding,
|
|
1311
|
+
workspaceDir,
|
|
1312
|
+
prompt: `${alias}${args.trim() ? ` ${args.trim()}` : ""}`,
|
|
1313
|
+
reason: "command",
|
|
1314
|
+
});
|
|
1315
|
+
return { text: `Sent ${alias} to Codex.` };
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
private async handleRenameCommand(
|
|
1319
|
+
conversation: ConversationTarget | null,
|
|
1320
|
+
binding: StoredBinding | null,
|
|
1321
|
+
args: string,
|
|
1322
|
+
): Promise<ReplyPayload> {
|
|
1323
|
+
if (!conversation || !binding) {
|
|
1324
|
+
return { text: "Bind this conversation to a Codex thread before renaming it." };
|
|
1325
|
+
}
|
|
1326
|
+
const parsed = parseRenameArgs(args);
|
|
1327
|
+
if (!parsed?.name) {
|
|
1328
|
+
const picker = await this.buildRenameStylePicker(conversation, binding, Boolean(parsed?.syncTopic));
|
|
1329
|
+
return buildReplyWithButtons(picker.text, picker.buttons);
|
|
1330
|
+
}
|
|
1331
|
+
await this.client.setThreadName({
|
|
1332
|
+
sessionKey: binding.sessionKey,
|
|
1333
|
+
threadId: binding.threadId,
|
|
1334
|
+
name: parsed.name,
|
|
1335
|
+
});
|
|
1336
|
+
if (parsed.syncTopic) {
|
|
1337
|
+
await this.renameConversationIfSupported(conversation, parsed.name);
|
|
1338
|
+
}
|
|
1339
|
+
await this.store.upsertBinding({
|
|
1340
|
+
...binding,
|
|
1341
|
+
threadTitle: parsed.name,
|
|
1342
|
+
updatedAt: Date.now(),
|
|
1343
|
+
});
|
|
1344
|
+
return { text: `Renamed the Codex thread to "${parsed.name}".` };
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
private async buildRenameStylePicker(
|
|
1348
|
+
conversation: ConversationTarget,
|
|
1349
|
+
binding: StoredBinding,
|
|
1350
|
+
syncTopic: boolean,
|
|
1351
|
+
): Promise<{ text: string; buttons: PluginInteractiveButtons }> {
|
|
1352
|
+
const threadState = await this.client
|
|
1353
|
+
.readThreadState({
|
|
1354
|
+
sessionKey: binding.sessionKey,
|
|
1355
|
+
threadId: binding.threadId,
|
|
1356
|
+
})
|
|
1357
|
+
.catch(() => undefined);
|
|
1358
|
+
const threadName = buildThreadOnlyName({
|
|
1359
|
+
title: threadState?.threadName || binding.threadTitle,
|
|
1360
|
+
projectKey: threadState?.cwd?.trim() || binding.workspaceDir,
|
|
1361
|
+
threadId: binding.threadId,
|
|
1362
|
+
});
|
|
1363
|
+
const threadProjectName = buildResumeTopicName({
|
|
1364
|
+
title: threadState?.threadName || binding.threadTitle,
|
|
1365
|
+
projectKey: threadState?.cwd?.trim() || binding.workspaceDir,
|
|
1366
|
+
threadId: binding.threadId,
|
|
1367
|
+
});
|
|
1368
|
+
const callbacks: Array<{ text: string; style: "thread-project" | "thread" }> = [];
|
|
1369
|
+
if (threadProjectName) {
|
|
1370
|
+
callbacks.push({
|
|
1371
|
+
text: threadProjectName,
|
|
1372
|
+
style: "thread-project",
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
if (threadName && threadName !== threadProjectName) {
|
|
1376
|
+
callbacks.push({
|
|
1377
|
+
text: threadName,
|
|
1378
|
+
style: "thread",
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
const buttons: PluginInteractiveButtons = [];
|
|
1382
|
+
for (const entry of callbacks) {
|
|
1383
|
+
const callback = await this.store.putCallback({
|
|
1384
|
+
kind: "rename-thread",
|
|
1385
|
+
conversation,
|
|
1386
|
+
style: entry.style,
|
|
1387
|
+
syncTopic,
|
|
1388
|
+
});
|
|
1389
|
+
buttons.push([
|
|
1390
|
+
{
|
|
1391
|
+
text: entry.text,
|
|
1392
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
1393
|
+
},
|
|
1394
|
+
]);
|
|
1395
|
+
}
|
|
1396
|
+
if (buttons.length === 0) {
|
|
1397
|
+
return { text: "Usage: /codex_rename [--sync] <new name>", buttons: [] };
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
text: syncTopic
|
|
1401
|
+
? "Choose a name style for the Codex thread and this conversation."
|
|
1402
|
+
: "Choose a name style for the Codex thread.",
|
|
1403
|
+
buttons,
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
private async applyRenameStyle(
|
|
1408
|
+
conversation: ConversationTarget,
|
|
1409
|
+
binding: StoredBinding,
|
|
1410
|
+
style: "thread-project" | "thread",
|
|
1411
|
+
syncTopic: boolean,
|
|
1412
|
+
): Promise<string> {
|
|
1413
|
+
const threadState = await this.client
|
|
1414
|
+
.readThreadState({
|
|
1415
|
+
sessionKey: binding.sessionKey,
|
|
1416
|
+
threadId: binding.threadId,
|
|
1417
|
+
})
|
|
1418
|
+
.catch(() => undefined);
|
|
1419
|
+
const name =
|
|
1420
|
+
style === "thread-project"
|
|
1421
|
+
? buildResumeTopicName({
|
|
1422
|
+
title: threadState?.threadName || binding.threadTitle,
|
|
1423
|
+
projectKey: threadState?.cwd?.trim() || binding.workspaceDir,
|
|
1424
|
+
threadId: binding.threadId,
|
|
1425
|
+
})
|
|
1426
|
+
: buildThreadOnlyName({
|
|
1427
|
+
title: threadState?.threadName || binding.threadTitle,
|
|
1428
|
+
projectKey: threadState?.cwd?.trim() || binding.workspaceDir,
|
|
1429
|
+
threadId: binding.threadId,
|
|
1430
|
+
});
|
|
1431
|
+
if (!name) {
|
|
1432
|
+
throw new Error("Unable to derive a Codex thread name.");
|
|
1433
|
+
}
|
|
1434
|
+
await this.client.setThreadName({
|
|
1435
|
+
sessionKey: binding.sessionKey,
|
|
1436
|
+
threadId: binding.threadId,
|
|
1437
|
+
name,
|
|
1438
|
+
});
|
|
1439
|
+
if (syncTopic) {
|
|
1440
|
+
await this.renameConversationIfSupported(conversation, name);
|
|
1441
|
+
}
|
|
1442
|
+
await this.store.upsertBinding({
|
|
1443
|
+
...binding,
|
|
1444
|
+
threadTitle: name,
|
|
1445
|
+
updatedAt: Date.now(),
|
|
1446
|
+
});
|
|
1447
|
+
return name;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
private async startTurn(params: {
|
|
1451
|
+
conversation: ConversationTarget;
|
|
1452
|
+
binding: StoredBinding | null;
|
|
1453
|
+
workspaceDir: string;
|
|
1454
|
+
prompt: string;
|
|
1455
|
+
reason: "command" | "inbound" | "plan";
|
|
1456
|
+
collaborationMode?: CollaborationMode;
|
|
1457
|
+
}): Promise<void> {
|
|
1458
|
+
const key = buildConversationKey(params.conversation);
|
|
1459
|
+
const existing = this.activeRuns.get(key);
|
|
1460
|
+
if (existing) {
|
|
1461
|
+
if (existing.mode === "plan" && (params.collaborationMode?.mode ?? "default") !== "plan") {
|
|
1462
|
+
this.activeRuns.delete(key);
|
|
1463
|
+
await existing.handle.interrupt().catch(() => undefined);
|
|
1464
|
+
} else {
|
|
1465
|
+
await existing.handle.queueMessage(params.prompt);
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
const typing = await this.startTypingLease(params.conversation);
|
|
1470
|
+
const run = this.client.startTurn({
|
|
1471
|
+
sessionKey: params.binding?.sessionKey,
|
|
1472
|
+
workspaceDir: params.workspaceDir,
|
|
1473
|
+
prompt: params.prompt,
|
|
1474
|
+
runId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1475
|
+
existingThreadId: params.binding?.threadId,
|
|
1476
|
+
model: this.settings.defaultModel,
|
|
1477
|
+
collaborationMode: params.collaborationMode,
|
|
1478
|
+
onPendingInput: async (state) => {
|
|
1479
|
+
await this.handlePendingInputState(params.conversation, params.workspaceDir, state, run);
|
|
1480
|
+
},
|
|
1481
|
+
onInterrupted: async () => {
|
|
1482
|
+
await this.sendText(params.conversation, "Codex stopped.");
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
this.activeRuns.set(key, {
|
|
1486
|
+
conversation: params.conversation,
|
|
1487
|
+
workspaceDir: params.workspaceDir,
|
|
1488
|
+
mode: params.collaborationMode?.mode === "plan" ? "plan" : "default",
|
|
1489
|
+
handle: run,
|
|
1490
|
+
});
|
|
1491
|
+
void (run.result as Promise<import("./types.js").TurnResult>)
|
|
1492
|
+
.then(async (result) => {
|
|
1493
|
+
const threadId = result.threadId || run.getThreadId();
|
|
1494
|
+
if (threadId) {
|
|
1495
|
+
const state = await this.client
|
|
1496
|
+
.readThreadState({
|
|
1497
|
+
sessionKey: params.binding?.sessionKey,
|
|
1498
|
+
threadId,
|
|
1499
|
+
})
|
|
1500
|
+
.catch(() => null);
|
|
1501
|
+
const nextBinding = await this.bindConversation(params.conversation, {
|
|
1502
|
+
threadId,
|
|
1503
|
+
workspaceDir: state?.cwd || params.workspaceDir,
|
|
1504
|
+
threadTitle: state?.threadName,
|
|
1505
|
+
});
|
|
1506
|
+
if (state?.threadName && nextBinding.threadTitle !== state.threadName) {
|
|
1507
|
+
await this.store.upsertBinding({
|
|
1508
|
+
...nextBinding,
|
|
1509
|
+
threadTitle: state.threadName,
|
|
1510
|
+
contextUsage: result.usage ?? nextBinding.contextUsage,
|
|
1511
|
+
updatedAt: Date.now(),
|
|
1512
|
+
});
|
|
1513
|
+
} else if (result.usage) {
|
|
1514
|
+
await this.store.upsertBinding({
|
|
1515
|
+
...nextBinding,
|
|
1516
|
+
contextUsage: result.usage,
|
|
1517
|
+
updatedAt: Date.now(),
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
await this.sendText(params.conversation, formatTurnCompletion(result));
|
|
1522
|
+
})
|
|
1523
|
+
.catch(async (error) => {
|
|
1524
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1525
|
+
await this.sendText(params.conversation, `Codex failed: ${message}`);
|
|
1526
|
+
})
|
|
1527
|
+
.finally(async () => {
|
|
1528
|
+
typing?.stop();
|
|
1529
|
+
this.activeRuns.delete(key);
|
|
1530
|
+
const pending = this.store.getPendingRequestByConversation(params.conversation);
|
|
1531
|
+
if (pending) {
|
|
1532
|
+
await this.store.removePendingRequest(pending.requestId);
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
private async startPlan(params: {
|
|
1538
|
+
conversation: ConversationTarget;
|
|
1539
|
+
binding: StoredBinding | null;
|
|
1540
|
+
workspaceDir: string;
|
|
1541
|
+
prompt: string;
|
|
1542
|
+
announceStart?: boolean;
|
|
1543
|
+
}): Promise<void> {
|
|
1544
|
+
const key = buildConversationKey(params.conversation);
|
|
1545
|
+
const existing = this.activeRuns.get(key);
|
|
1546
|
+
if (existing) {
|
|
1547
|
+
await existing.handle.interrupt();
|
|
1548
|
+
}
|
|
1549
|
+
if (params.announceStart !== false) {
|
|
1550
|
+
await this.sendText(
|
|
1551
|
+
params.conversation,
|
|
1552
|
+
"Starting Codex plan mode. I’ll relay the questions and final plan as they arrive.",
|
|
1553
|
+
);
|
|
1554
|
+
}
|
|
1555
|
+
const typing = await this.startTypingLease(params.conversation);
|
|
1556
|
+
const threadState =
|
|
1557
|
+
params.binding?.threadId
|
|
1558
|
+
? await this.client
|
|
1559
|
+
.readThreadState({
|
|
1560
|
+
sessionKey: params.binding.sessionKey,
|
|
1561
|
+
threadId: params.binding.threadId,
|
|
1562
|
+
})
|
|
1563
|
+
.catch(() => null)
|
|
1564
|
+
: null;
|
|
1565
|
+
let keepaliveSent = false;
|
|
1566
|
+
const progressTimer = setTimeout(() => {
|
|
1567
|
+
void (async () => {
|
|
1568
|
+
if (keepaliveSent) {
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
keepaliveSent = true;
|
|
1572
|
+
await this.sendText(params.conversation, "Codex is still planning...");
|
|
1573
|
+
})();
|
|
1574
|
+
}, PLAN_PROGRESS_DELAY_MS);
|
|
1575
|
+
const run = this.client.startTurn({
|
|
1576
|
+
sessionKey: params.binding?.sessionKey,
|
|
1577
|
+
workspaceDir: params.workspaceDir,
|
|
1578
|
+
prompt: params.prompt,
|
|
1579
|
+
runId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1580
|
+
existingThreadId: params.binding?.threadId,
|
|
1581
|
+
model: threadState?.model || this.settings.defaultModel,
|
|
1582
|
+
collaborationMode: {
|
|
1583
|
+
mode: "plan",
|
|
1584
|
+
settings: {
|
|
1585
|
+
model: threadState?.model || this.settings.defaultModel,
|
|
1586
|
+
reasoningEffort: threadState?.reasoningEffort,
|
|
1587
|
+
developerInstructions: null,
|
|
1588
|
+
},
|
|
1589
|
+
},
|
|
1590
|
+
onPendingInput: async (state) => {
|
|
1591
|
+
this.api.logger.debug(
|
|
1592
|
+
`codex plan pending input ${state ? `received (questionnaire=${state.questionnaire ? "yes" : "no"})` : "cleared"}`,
|
|
1593
|
+
);
|
|
1594
|
+
await this.handlePendingInputState(params.conversation, params.workspaceDir, state, run);
|
|
1595
|
+
},
|
|
1596
|
+
onInterrupted: async () => {
|
|
1597
|
+
await this.sendText(params.conversation, formatInterruptedText("plan"));
|
|
1598
|
+
},
|
|
1599
|
+
});
|
|
1600
|
+
this.activeRuns.set(key, {
|
|
1601
|
+
conversation: params.conversation,
|
|
1602
|
+
workspaceDir: params.workspaceDir,
|
|
1603
|
+
mode: "plan",
|
|
1604
|
+
handle: run,
|
|
1605
|
+
});
|
|
1606
|
+
void (run.result as Promise<import("./types.js").TurnResult>)
|
|
1607
|
+
.then(async (result) => {
|
|
1608
|
+
const threadId = result.threadId || run.getThreadId();
|
|
1609
|
+
if (threadId) {
|
|
1610
|
+
const state = await this.client
|
|
1611
|
+
.readThreadState({
|
|
1612
|
+
sessionKey: params.binding?.sessionKey,
|
|
1613
|
+
threadId,
|
|
1614
|
+
})
|
|
1615
|
+
.catch(() => null);
|
|
1616
|
+
const nextBinding = await this.bindConversation(params.conversation, {
|
|
1617
|
+
threadId,
|
|
1618
|
+
workspaceDir: state?.cwd || params.workspaceDir,
|
|
1619
|
+
threadTitle: state?.threadName,
|
|
1620
|
+
});
|
|
1621
|
+
await this.store.upsertBinding({
|
|
1622
|
+
...nextBinding,
|
|
1623
|
+
contextUsage: result.usage ?? nextBinding.contextUsage,
|
|
1624
|
+
updatedAt: Date.now(),
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
if (result.aborted) {
|
|
1628
|
+
await this.sendText(params.conversation, formatInterruptedText("plan"));
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (result.planArtifact) {
|
|
1632
|
+
const implement = await this.store.putCallback({
|
|
1633
|
+
kind: "run-prompt",
|
|
1634
|
+
conversation: params.conversation,
|
|
1635
|
+
workspaceDir: params.workspaceDir,
|
|
1636
|
+
prompt: "Implement the plan.",
|
|
1637
|
+
collaborationMode: {
|
|
1638
|
+
mode: "default",
|
|
1639
|
+
settings: {
|
|
1640
|
+
model: threadState?.model || this.settings.defaultModel,
|
|
1641
|
+
reasoningEffort: threadState?.reasoningEffort,
|
|
1642
|
+
developerInstructions: null,
|
|
1643
|
+
},
|
|
1644
|
+
},
|
|
1645
|
+
});
|
|
1646
|
+
const stay = await this.store.putCallback({
|
|
1647
|
+
kind: "reply-text",
|
|
1648
|
+
conversation: params.conversation,
|
|
1649
|
+
text: "Okay. Staying in plan mode.",
|
|
1650
|
+
});
|
|
1651
|
+
const delivery = await this.buildPlanDelivery(result.planArtifact);
|
|
1652
|
+
await this.sendText(params.conversation, delivery.summaryText);
|
|
1653
|
+
if (delivery.attachmentPath) {
|
|
1654
|
+
const attachmentSent = await this.sendReply(params.conversation, {
|
|
1655
|
+
mediaUrl: delivery.attachmentPath,
|
|
1656
|
+
}).catch((error) => {
|
|
1657
|
+
this.api.logger.warn(`codex plan attachment send failed: ${String(error)}`);
|
|
1658
|
+
return false;
|
|
1659
|
+
});
|
|
1660
|
+
if (!attachmentSent && delivery.attachmentFallbackText) {
|
|
1661
|
+
await this.sendText(params.conversation, delivery.attachmentFallbackText);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
await this.sendText(params.conversation, "Implement this plan?", {
|
|
1665
|
+
buttons: [
|
|
1666
|
+
[
|
|
1667
|
+
{
|
|
1668
|
+
text: "Yes, implement this plan",
|
|
1669
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${implement.token}`,
|
|
1670
|
+
},
|
|
1671
|
+
],
|
|
1672
|
+
[
|
|
1673
|
+
{
|
|
1674
|
+
text: "No, stay in Plan mode",
|
|
1675
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${stay.token}`,
|
|
1676
|
+
},
|
|
1677
|
+
],
|
|
1678
|
+
],
|
|
1679
|
+
});
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (result.text?.trim()) {
|
|
1683
|
+
await this.sendText(params.conversation, result.text.trim());
|
|
1684
|
+
}
|
|
1685
|
+
})
|
|
1686
|
+
.catch(async (error) => {
|
|
1687
|
+
await this.sendText(params.conversation, formatFailureText("plan", error));
|
|
1688
|
+
})
|
|
1689
|
+
.finally(async () => {
|
|
1690
|
+
clearTimeout(progressTimer);
|
|
1691
|
+
typing?.stop();
|
|
1692
|
+
this.activeRuns.delete(key);
|
|
1693
|
+
const pending = this.store.getPendingRequestByConversation(params.conversation);
|
|
1694
|
+
if (pending) {
|
|
1695
|
+
await this.store.removePendingRequest(pending.requestId);
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
private async startReview(params: {
|
|
1701
|
+
conversation: ConversationTarget;
|
|
1702
|
+
binding: StoredBinding;
|
|
1703
|
+
workspaceDir: string;
|
|
1704
|
+
target: { type: "uncommittedChanges" } | { type: "custom"; instructions: string };
|
|
1705
|
+
announceStart?: boolean;
|
|
1706
|
+
}): Promise<void> {
|
|
1707
|
+
const key = buildConversationKey(params.conversation);
|
|
1708
|
+
const existing = this.activeRuns.get(key);
|
|
1709
|
+
if (existing) {
|
|
1710
|
+
await existing.handle.interrupt();
|
|
1711
|
+
}
|
|
1712
|
+
if (params.announceStart !== false) {
|
|
1713
|
+
await this.sendText(
|
|
1714
|
+
params.conversation,
|
|
1715
|
+
params.target.type === "custom"
|
|
1716
|
+
? "Starting Codex review with your custom focus. I’ll send the findings when the review finishes."
|
|
1717
|
+
: "Starting Codex review of the current changes. I’ll send the findings when the review finishes.",
|
|
1718
|
+
);
|
|
1719
|
+
}
|
|
1720
|
+
const typing = await this.startTypingLease(params.conversation);
|
|
1721
|
+
let keepaliveSent = false;
|
|
1722
|
+
const progressTimer = setTimeout(() => {
|
|
1723
|
+
void (async () => {
|
|
1724
|
+
if (keepaliveSent) {
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
keepaliveSent = true;
|
|
1728
|
+
await this.sendText(params.conversation, "Codex is still reviewing...");
|
|
1729
|
+
})();
|
|
1730
|
+
}, REVIEW_PROGRESS_DELAY_MS);
|
|
1731
|
+
const run = this.client.startReview({
|
|
1732
|
+
sessionKey: params.binding.sessionKey,
|
|
1733
|
+
workspaceDir: params.workspaceDir,
|
|
1734
|
+
threadId: params.binding.threadId,
|
|
1735
|
+
runId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
1736
|
+
target: params.target,
|
|
1737
|
+
onPendingInput: async (state) => {
|
|
1738
|
+
await this.handlePendingInputState(params.conversation, params.workspaceDir, state, run);
|
|
1739
|
+
},
|
|
1740
|
+
onInterrupted: async () => {
|
|
1741
|
+
await this.sendText(params.conversation, "Codex review stopped.");
|
|
1742
|
+
},
|
|
1743
|
+
});
|
|
1744
|
+
this.activeRuns.set(key, {
|
|
1745
|
+
conversation: params.conversation,
|
|
1746
|
+
workspaceDir: params.workspaceDir,
|
|
1747
|
+
mode: "review",
|
|
1748
|
+
handle: run,
|
|
1749
|
+
});
|
|
1750
|
+
void (run.result as Promise<import("./types.js").ReviewResult>)
|
|
1751
|
+
.then(async (result) => {
|
|
1752
|
+
if (result.aborted) {
|
|
1753
|
+
await this.sendText(params.conversation, formatInterruptedText("review"));
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const parsed = parseCodexReviewOutput(result.reviewText);
|
|
1757
|
+
if (parsed.summary) {
|
|
1758
|
+
await this.sendText(params.conversation, parsed.summary);
|
|
1759
|
+
}
|
|
1760
|
+
if (parsed.findings.length === 0) {
|
|
1761
|
+
await this.sendText(params.conversation, "No review findings.");
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
for (const [index, finding] of parsed.findings.entries()) {
|
|
1765
|
+
await this.sendText(
|
|
1766
|
+
params.conversation,
|
|
1767
|
+
formatCodexReviewFindingMessage({
|
|
1768
|
+
finding,
|
|
1769
|
+
index,
|
|
1770
|
+
}),
|
|
1771
|
+
);
|
|
1772
|
+
}
|
|
1773
|
+
const buttons: PluginInteractiveButtons = [];
|
|
1774
|
+
for (const [index, finding] of parsed.findings.slice(0, 6).entries()) {
|
|
1775
|
+
const callback = await this.store.putCallback({
|
|
1776
|
+
kind: "run-prompt",
|
|
1777
|
+
conversation: params.conversation,
|
|
1778
|
+
workspaceDir: params.workspaceDir,
|
|
1779
|
+
prompt: [
|
|
1780
|
+
"Please implement this Codex review finding:",
|
|
1781
|
+
"",
|
|
1782
|
+
formatCodexReviewFindingMessage({ finding, index }),
|
|
1783
|
+
].join("\n"),
|
|
1784
|
+
});
|
|
1785
|
+
buttons.push([
|
|
1786
|
+
{
|
|
1787
|
+
text: finding.priorityLabel ? `Implement ${finding.priorityLabel}` : `Implement #${index + 1}`,
|
|
1788
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
1789
|
+
},
|
|
1790
|
+
]);
|
|
1791
|
+
}
|
|
1792
|
+
const allFixes = await this.store.putCallback({
|
|
1793
|
+
kind: "run-prompt",
|
|
1794
|
+
conversation: params.conversation,
|
|
1795
|
+
workspaceDir: params.workspaceDir,
|
|
1796
|
+
prompt: [
|
|
1797
|
+
"Please implement fixes for all of these Codex review findings:",
|
|
1798
|
+
"",
|
|
1799
|
+
...parsed.findings.map((finding, index) =>
|
|
1800
|
+
`${index + 1}. ${finding.priorityLabel ? `[${finding.priorityLabel}] ` : ""}${finding.title}${
|
|
1801
|
+
finding.location ? `\n ${finding.location}` : ""
|
|
1802
|
+
}${finding.body?.trim() ? `\n ${finding.body.trim().replace(/\n/g, "\n ")}` : ""}`,
|
|
1803
|
+
),
|
|
1804
|
+
].join("\n"),
|
|
1805
|
+
});
|
|
1806
|
+
buttons.push([
|
|
1807
|
+
{
|
|
1808
|
+
text: "Implement All Fixes",
|
|
1809
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${allFixes.token}`,
|
|
1810
|
+
},
|
|
1811
|
+
]);
|
|
1812
|
+
await this.sendText(
|
|
1813
|
+
params.conversation,
|
|
1814
|
+
"Choose a review finding to implement, or implement them all.",
|
|
1815
|
+
{ buttons },
|
|
1816
|
+
);
|
|
1817
|
+
})
|
|
1818
|
+
.catch(async (error) => {
|
|
1819
|
+
await this.sendText(params.conversation, formatFailureText("review", error));
|
|
1820
|
+
})
|
|
1821
|
+
.finally(async () => {
|
|
1822
|
+
clearTimeout(progressTimer);
|
|
1823
|
+
typing?.stop();
|
|
1824
|
+
this.activeRuns.delete(key);
|
|
1825
|
+
const pending = this.store.getPendingRequestByConversation(params.conversation);
|
|
1826
|
+
if (pending) {
|
|
1827
|
+
await this.store.removePendingRequest(pending.requestId);
|
|
1828
|
+
}
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
private async handlePendingInputState(
|
|
1833
|
+
conversation: ConversationTarget,
|
|
1834
|
+
workspaceDir: string,
|
|
1835
|
+
state: PendingInputState | null,
|
|
1836
|
+
run: ActiveCodexRun,
|
|
1837
|
+
): Promise<void> {
|
|
1838
|
+
if (!state) {
|
|
1839
|
+
const existing = this.store.getPendingRequestByConversation(conversation);
|
|
1840
|
+
if (existing) {
|
|
1841
|
+
await this.store.removePendingRequest(existing.requestId);
|
|
1842
|
+
}
|
|
1843
|
+
return;
|
|
1844
|
+
}
|
|
1845
|
+
if (state.questionnaire) {
|
|
1846
|
+
await this.store.upsertPendingRequest({
|
|
1847
|
+
requestId: state.requestId,
|
|
1848
|
+
conversation,
|
|
1849
|
+
threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "",
|
|
1850
|
+
workspaceDir,
|
|
1851
|
+
state,
|
|
1852
|
+
updatedAt: Date.now(),
|
|
1853
|
+
});
|
|
1854
|
+
await this.sendPendingQuestionnaire(conversation, state);
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
const callbacks = await Promise.all(
|
|
1858
|
+
(state.actions ?? []).map(async (_action, actionIndex) => {
|
|
1859
|
+
return await this.store.putCallback({
|
|
1860
|
+
kind: "pending-input",
|
|
1861
|
+
conversation,
|
|
1862
|
+
requestId: state.requestId,
|
|
1863
|
+
actionIndex,
|
|
1864
|
+
ttlMs: Math.max(1_000, state.expiresAt - Date.now()),
|
|
1865
|
+
});
|
|
1866
|
+
}),
|
|
1867
|
+
);
|
|
1868
|
+
const buttons = this.buildPendingButtons(state, callbacks);
|
|
1869
|
+
await this.store.upsertPendingRequest({
|
|
1870
|
+
requestId: state.requestId,
|
|
1871
|
+
conversation,
|
|
1872
|
+
threadId: run.getThreadId() ?? this.store.getBinding(conversation)?.threadId ?? "",
|
|
1873
|
+
workspaceDir,
|
|
1874
|
+
state,
|
|
1875
|
+
updatedAt: Date.now(),
|
|
1876
|
+
});
|
|
1877
|
+
await this.sendText(conversation, state.promptText ?? "Codex needs input.", { buttons });
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
private async sendPendingQuestionnaire(
|
|
1881
|
+
conversation: ConversationTarget,
|
|
1882
|
+
state: PendingInputState,
|
|
1883
|
+
opts?: {
|
|
1884
|
+
editMessage?: (text: string, buttons: PluginInteractiveButtons) => Promise<void>;
|
|
1885
|
+
},
|
|
1886
|
+
): Promise<void> {
|
|
1887
|
+
const questionnaire = state.questionnaire;
|
|
1888
|
+
if (!questionnaire) {
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const buttons = await this.buildPendingQuestionnaireButtons(conversation, state);
|
|
1892
|
+
const text = formatPendingQuestionnairePrompt(questionnaire);
|
|
1893
|
+
if (opts?.editMessage) {
|
|
1894
|
+
await opts.editMessage(text, buttons);
|
|
1895
|
+
return;
|
|
1896
|
+
}
|
|
1897
|
+
await this.sendText(conversation, text, { buttons });
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
private async buildPendingQuestionnaireButtons(
|
|
1901
|
+
conversation: ConversationTarget,
|
|
1902
|
+
state: PendingInputState,
|
|
1903
|
+
): Promise<PluginInteractiveButtons> {
|
|
1904
|
+
const questionnaire = state.questionnaire;
|
|
1905
|
+
if (!questionnaire) {
|
|
1906
|
+
return [];
|
|
1907
|
+
}
|
|
1908
|
+
const question = questionnaire.questions[questionnaire.currentIndex];
|
|
1909
|
+
if (!question) {
|
|
1910
|
+
return [];
|
|
1911
|
+
}
|
|
1912
|
+
const rows: PluginInteractiveButtons = [];
|
|
1913
|
+
for (let optionIndex = 0; optionIndex < question.options.length; optionIndex += 1) {
|
|
1914
|
+
const option = question.options[optionIndex];
|
|
1915
|
+
if (!option) {
|
|
1916
|
+
continue;
|
|
1917
|
+
}
|
|
1918
|
+
const callback = await this.store.putCallback({
|
|
1919
|
+
kind: "pending-questionnaire",
|
|
1920
|
+
conversation,
|
|
1921
|
+
requestId: state.requestId,
|
|
1922
|
+
questionIndex: question.index,
|
|
1923
|
+
action: "select",
|
|
1924
|
+
optionIndex,
|
|
1925
|
+
ttlMs: Math.max(1_000, state.expiresAt - Date.now()),
|
|
1926
|
+
});
|
|
1927
|
+
rows.push([
|
|
1928
|
+
{
|
|
1929
|
+
text: `${option.key}. ${option.label}`,
|
|
1930
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
1931
|
+
},
|
|
1932
|
+
]);
|
|
1933
|
+
}
|
|
1934
|
+
const navRow: PluginInteractiveButtons[number] = [];
|
|
1935
|
+
if (questionnaire.currentIndex > 0) {
|
|
1936
|
+
const prev = await this.store.putCallback({
|
|
1937
|
+
kind: "pending-questionnaire",
|
|
1938
|
+
conversation,
|
|
1939
|
+
requestId: state.requestId,
|
|
1940
|
+
questionIndex: questionnaire.currentIndex,
|
|
1941
|
+
action: "prev",
|
|
1942
|
+
ttlMs: Math.max(1_000, state.expiresAt - Date.now()),
|
|
1943
|
+
});
|
|
1944
|
+
navRow.push({
|
|
1945
|
+
text: "Prev",
|
|
1946
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${prev.token}`,
|
|
1947
|
+
});
|
|
1948
|
+
}
|
|
1949
|
+
if (
|
|
1950
|
+
questionnaire.currentIndex < questionnaire.questions.length - 1 &&
|
|
1951
|
+
questionnaireCurrentQuestionHasAnswer(questionnaire)
|
|
1952
|
+
) {
|
|
1953
|
+
const next = await this.store.putCallback({
|
|
1954
|
+
kind: "pending-questionnaire",
|
|
1955
|
+
conversation,
|
|
1956
|
+
requestId: state.requestId,
|
|
1957
|
+
questionIndex: questionnaire.currentIndex,
|
|
1958
|
+
action: "next",
|
|
1959
|
+
ttlMs: Math.max(1_000, state.expiresAt - Date.now()),
|
|
1960
|
+
});
|
|
1961
|
+
navRow.push({
|
|
1962
|
+
text: "Next",
|
|
1963
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${next.token}`,
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
if (navRow.length > 0) {
|
|
1967
|
+
rows.push(navRow);
|
|
1968
|
+
}
|
|
1969
|
+
const freeform = await this.store.putCallback({
|
|
1970
|
+
kind: "pending-questionnaire",
|
|
1971
|
+
conversation,
|
|
1972
|
+
requestId: state.requestId,
|
|
1973
|
+
questionIndex: questionnaire.currentIndex,
|
|
1974
|
+
action: "freeform",
|
|
1975
|
+
ttlMs: Math.max(1_000, state.expiresAt - Date.now()),
|
|
1976
|
+
});
|
|
1977
|
+
rows.push([
|
|
1978
|
+
{
|
|
1979
|
+
text: "Use Free Form",
|
|
1980
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${freeform.token}`,
|
|
1981
|
+
},
|
|
1982
|
+
]);
|
|
1983
|
+
return rows;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
private buildPendingButtons(
|
|
1987
|
+
state: PendingInputState,
|
|
1988
|
+
callbacks: CallbackAction[],
|
|
1989
|
+
): PluginInteractiveButtons | undefined {
|
|
1990
|
+
const actions = state.actions ?? [];
|
|
1991
|
+
if (actions.length === 0 || callbacks.length === 0) {
|
|
1992
|
+
return undefined;
|
|
1993
|
+
}
|
|
1994
|
+
const rows: PluginInteractiveButtons = [];
|
|
1995
|
+
for (let index = 0; index < actions.length; index += 2) {
|
|
1996
|
+
rows.push(
|
|
1997
|
+
actions.slice(index, index + 2).map((action, offset) => ({
|
|
1998
|
+
text: action.label,
|
|
1999
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callbacks[index + offset]?.token ?? requestToken(state.requestId)}`,
|
|
2000
|
+
})),
|
|
2001
|
+
);
|
|
2002
|
+
}
|
|
2003
|
+
return rows;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
private async handlePendingQuestionnaireFreeformAnswer(
|
|
2007
|
+
conversation: ConversationTarget,
|
|
2008
|
+
pending: StoredPendingRequest,
|
|
2009
|
+
run: ActiveCodexRun,
|
|
2010
|
+
text: string,
|
|
2011
|
+
): Promise<boolean> {
|
|
2012
|
+
const questionnaire = pending.state.questionnaire;
|
|
2013
|
+
const answerText = text.trim();
|
|
2014
|
+
if (!questionnaire || !answerText) {
|
|
2015
|
+
return false;
|
|
2016
|
+
}
|
|
2017
|
+
questionnaire.answers[questionnaire.currentIndex] = {
|
|
2018
|
+
kind: "text",
|
|
2019
|
+
text: answerText,
|
|
2020
|
+
};
|
|
2021
|
+
questionnaire.awaitingFreeform = false;
|
|
2022
|
+
pending.updatedAt = Date.now();
|
|
2023
|
+
await this.store.upsertPendingRequest(pending);
|
|
2024
|
+
if (questionnaireIsComplete(questionnaire)) {
|
|
2025
|
+
const submitted = await run.submitPendingInputPayload(
|
|
2026
|
+
buildPendingQuestionnaireResponse(questionnaire),
|
|
2027
|
+
);
|
|
2028
|
+
if (!submitted) {
|
|
2029
|
+
return false;
|
|
2030
|
+
}
|
|
2031
|
+
await this.store.removePendingRequest(pending.requestId);
|
|
2032
|
+
await this.sendText(conversation, "Recorded your answers and sent them to Codex.");
|
|
2033
|
+
return true;
|
|
2034
|
+
}
|
|
2035
|
+
questionnaire.currentIndex = Math.min(
|
|
2036
|
+
questionnaire.questions.length - 1,
|
|
2037
|
+
questionnaire.currentIndex + 1,
|
|
2038
|
+
);
|
|
2039
|
+
pending.updatedAt = Date.now();
|
|
2040
|
+
await this.store.upsertPendingRequest(pending);
|
|
2041
|
+
await this.sendPendingQuestionnaire(conversation, pending.state);
|
|
2042
|
+
return true;
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
private resolveThreadWorkspaceDir(
|
|
2046
|
+
parsed: ReturnType<typeof parseThreadSelectionArgs>,
|
|
2047
|
+
binding: StoredBinding | null,
|
|
2048
|
+
useAllProjectsDefault: boolean,
|
|
2049
|
+
): string | undefined {
|
|
2050
|
+
if (parsed.cwd) {
|
|
2051
|
+
return parsed.cwd;
|
|
2052
|
+
}
|
|
2053
|
+
if (parsed.includeAll || useAllProjectsDefault) {
|
|
2054
|
+
return undefined;
|
|
2055
|
+
}
|
|
2056
|
+
return resolveWorkspaceDir({
|
|
2057
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
2058
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
2059
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
private async listPickerThreads(
|
|
2064
|
+
binding: StoredBinding | null,
|
|
2065
|
+
params: {
|
|
2066
|
+
parsed: ReturnType<typeof parseThreadSelectionArgs>;
|
|
2067
|
+
projectName?: string;
|
|
2068
|
+
filterProjectsOnly?: boolean;
|
|
2069
|
+
},
|
|
2070
|
+
) {
|
|
2071
|
+
const workspaceDir = this.resolveThreadWorkspaceDir(
|
|
2072
|
+
params.parsed,
|
|
2073
|
+
binding,
|
|
2074
|
+
params.filterProjectsOnly || Boolean(params.projectName),
|
|
2075
|
+
);
|
|
2076
|
+
const threads = await this.client.listThreads({
|
|
2077
|
+
sessionKey: binding?.sessionKey,
|
|
2078
|
+
workspaceDir,
|
|
2079
|
+
filter: params.filterProjectsOnly ? undefined : params.parsed.query || undefined,
|
|
2080
|
+
});
|
|
2081
|
+
return {
|
|
2082
|
+
workspaceDir,
|
|
2083
|
+
threads: filterThreadsByProjectName(threads, params.projectName),
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
private async buildThreadPickerButtons(params: {
|
|
2088
|
+
conversation: ConversationTarget;
|
|
2089
|
+
syncTopic?: boolean;
|
|
2090
|
+
threads: Array<{ threadId: string; title?: string; projectKey?: string }>;
|
|
2091
|
+
showProjectName: boolean;
|
|
2092
|
+
}): Promise<PluginInteractiveButtons | undefined> {
|
|
2093
|
+
if (params.threads.length === 0) {
|
|
2094
|
+
return undefined;
|
|
2095
|
+
}
|
|
2096
|
+
const rows: PluginInteractiveButtons = [];
|
|
2097
|
+
for (const thread of params.threads) {
|
|
2098
|
+
const isWorktree = this.isWorktreePath(thread.projectKey);
|
|
2099
|
+
const hasChanges = await this.readThreadHasChanges(thread.projectKey);
|
|
2100
|
+
const callback = await this.store.putCallback({
|
|
2101
|
+
kind: "resume-thread",
|
|
2102
|
+
conversation: params.conversation,
|
|
2103
|
+
threadId: thread.threadId,
|
|
2104
|
+
workspaceDir: thread.projectKey?.trim() || this.settings.defaultWorkspaceDir || process.cwd(),
|
|
2105
|
+
syncTopic: params.syncTopic,
|
|
2106
|
+
});
|
|
2107
|
+
rows.push([
|
|
2108
|
+
{
|
|
2109
|
+
text: formatThreadButtonLabel({
|
|
2110
|
+
thread,
|
|
2111
|
+
includeProjectSuffix: params.showProjectName,
|
|
2112
|
+
isWorktree,
|
|
2113
|
+
hasChanges,
|
|
2114
|
+
}),
|
|
2115
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
2116
|
+
},
|
|
2117
|
+
]);
|
|
2118
|
+
}
|
|
2119
|
+
return rows;
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
private async appendThreadPickerControls(params: {
|
|
2123
|
+
conversation: ConversationTarget;
|
|
2124
|
+
buttons: PluginInteractiveButtons;
|
|
2125
|
+
parsed: ReturnType<typeof parseThreadSelectionArgs>;
|
|
2126
|
+
projectName?: string;
|
|
2127
|
+
page: number;
|
|
2128
|
+
totalPages: number;
|
|
2129
|
+
}): Promise<PluginInteractiveButtons> {
|
|
2130
|
+
if (params.totalPages > 1) {
|
|
2131
|
+
const navRow: PluginInteractiveButtons[number] = [];
|
|
2132
|
+
if (params.page > 0) {
|
|
2133
|
+
const prev = await this.store.putCallback({
|
|
2134
|
+
kind: "picker-view",
|
|
2135
|
+
conversation: params.conversation,
|
|
2136
|
+
view: {
|
|
2137
|
+
mode: "threads",
|
|
2138
|
+
includeAll: params.parsed.includeAll,
|
|
2139
|
+
syncTopic: params.parsed.syncTopic,
|
|
2140
|
+
workspaceDir: params.parsed.cwd,
|
|
2141
|
+
query: params.parsed.query || undefined,
|
|
2142
|
+
projectName: params.projectName,
|
|
2143
|
+
page: params.page - 1,
|
|
2144
|
+
},
|
|
2145
|
+
});
|
|
2146
|
+
navRow.push({
|
|
2147
|
+
text: "◀ Prev",
|
|
2148
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${prev.token}`,
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
if (params.page + 1 < params.totalPages) {
|
|
2152
|
+
const next = await this.store.putCallback({
|
|
2153
|
+
kind: "picker-view",
|
|
2154
|
+
conversation: params.conversation,
|
|
2155
|
+
view: {
|
|
2156
|
+
mode: "threads",
|
|
2157
|
+
includeAll: params.parsed.includeAll,
|
|
2158
|
+
syncTopic: params.parsed.syncTopic,
|
|
2159
|
+
workspaceDir: params.parsed.cwd,
|
|
2160
|
+
query: params.parsed.query || undefined,
|
|
2161
|
+
projectName: params.projectName,
|
|
2162
|
+
page: params.page + 1,
|
|
2163
|
+
},
|
|
2164
|
+
});
|
|
2165
|
+
navRow.push({
|
|
2166
|
+
text: "Next ▶",
|
|
2167
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${next.token}`,
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
if (navRow.length > 0) {
|
|
2171
|
+
params.buttons.push(navRow);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
const projects = await this.store.putCallback({
|
|
2176
|
+
kind: "picker-view",
|
|
2177
|
+
conversation: params.conversation,
|
|
2178
|
+
view: {
|
|
2179
|
+
mode: "projects",
|
|
2180
|
+
includeAll: true,
|
|
2181
|
+
syncTopic: params.parsed.syncTopic,
|
|
2182
|
+
workspaceDir: params.parsed.cwd,
|
|
2183
|
+
page: 0,
|
|
2184
|
+
},
|
|
2185
|
+
});
|
|
2186
|
+
params.buttons.push([
|
|
2187
|
+
{
|
|
2188
|
+
text: params.projectName ? "Projects" : "Browse Projects",
|
|
2189
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${projects.token}`,
|
|
2190
|
+
},
|
|
2191
|
+
]);
|
|
2192
|
+
return params.buttons;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
private async renderThreadPicker(
|
|
2196
|
+
conversation: ConversationTarget,
|
|
2197
|
+
binding: StoredBinding | null,
|
|
2198
|
+
parsed: ReturnType<typeof parseThreadSelectionArgs>,
|
|
2199
|
+
page: number,
|
|
2200
|
+
projectName?: string,
|
|
2201
|
+
): Promise<PickerRender> {
|
|
2202
|
+
const { workspaceDir, threads } = await this.listPickerThreads(binding, {
|
|
2203
|
+
parsed,
|
|
2204
|
+
projectName,
|
|
2205
|
+
});
|
|
2206
|
+
const pageResult = paginateItems(threads, page);
|
|
2207
|
+
const distinctProjects = new Set(
|
|
2208
|
+
threads.map((thread) => getProjectName(thread.projectKey)).filter(Boolean),
|
|
2209
|
+
);
|
|
2210
|
+
const threadButtons =
|
|
2211
|
+
(await this.buildThreadPickerButtons({
|
|
2212
|
+
conversation,
|
|
2213
|
+
syncTopic: parsed.syncTopic,
|
|
2214
|
+
threads: pageResult.items,
|
|
2215
|
+
showProjectName: !projectName && distinctProjects.size > 1,
|
|
2216
|
+
})) ?? [];
|
|
2217
|
+
return {
|
|
2218
|
+
text: formatThreadPickerIntro({
|
|
2219
|
+
page: pageResult.page,
|
|
2220
|
+
totalPages: pageResult.totalPages,
|
|
2221
|
+
totalItems: pageResult.totalItems,
|
|
2222
|
+
includeAll: workspaceDir == null,
|
|
2223
|
+
syncTopic: parsed.syncTopic,
|
|
2224
|
+
workspaceDir,
|
|
2225
|
+
projectName,
|
|
2226
|
+
}),
|
|
2227
|
+
buttons: await this.appendThreadPickerControls({
|
|
2228
|
+
conversation,
|
|
2229
|
+
buttons: threadButtons,
|
|
2230
|
+
parsed,
|
|
2231
|
+
projectName,
|
|
2232
|
+
page: pageResult.page,
|
|
2233
|
+
totalPages: pageResult.totalPages,
|
|
2234
|
+
}),
|
|
2235
|
+
};
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
private async renderProjectPicker(
|
|
2239
|
+
conversation: ConversationTarget,
|
|
2240
|
+
binding: StoredBinding | null,
|
|
2241
|
+
parsed: ReturnType<typeof parseThreadSelectionArgs>,
|
|
2242
|
+
page: number,
|
|
2243
|
+
): Promise<PickerRender> {
|
|
2244
|
+
const { workspaceDir, threads } = await this.listPickerThreads(binding, {
|
|
2245
|
+
parsed,
|
|
2246
|
+
filterProjectsOnly: true,
|
|
2247
|
+
});
|
|
2248
|
+
const projects = paginateItems(listProjects(threads, parsed.query), page);
|
|
2249
|
+
const buttons: PluginInteractiveButtons = [];
|
|
2250
|
+
for (const project of projects.items) {
|
|
2251
|
+
const callback = await this.store.putCallback({
|
|
2252
|
+
kind: "picker-view",
|
|
2253
|
+
conversation,
|
|
2254
|
+
view: {
|
|
2255
|
+
mode: "threads",
|
|
2256
|
+
includeAll: true,
|
|
2257
|
+
syncTopic: parsed.syncTopic,
|
|
2258
|
+
workspaceDir: parsed.cwd,
|
|
2259
|
+
projectName: project.name,
|
|
2260
|
+
page: 0,
|
|
2261
|
+
},
|
|
2262
|
+
});
|
|
2263
|
+
buttons.push([
|
|
2264
|
+
{
|
|
2265
|
+
text: `${project.name} (${project.threadCount})`,
|
|
2266
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${callback.token}`,
|
|
2267
|
+
},
|
|
2268
|
+
]);
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
if (projects.totalPages > 1) {
|
|
2272
|
+
const navRow: PluginInteractiveButtons[number] = [];
|
|
2273
|
+
if (projects.page > 0) {
|
|
2274
|
+
const prev = await this.store.putCallback({
|
|
2275
|
+
kind: "picker-view",
|
|
2276
|
+
conversation,
|
|
2277
|
+
view: {
|
|
2278
|
+
mode: "projects",
|
|
2279
|
+
includeAll: true,
|
|
2280
|
+
syncTopic: parsed.syncTopic,
|
|
2281
|
+
workspaceDir: parsed.cwd,
|
|
2282
|
+
query: parsed.query || undefined,
|
|
2283
|
+
page: projects.page - 1,
|
|
2284
|
+
},
|
|
2285
|
+
});
|
|
2286
|
+
navRow.push({
|
|
2287
|
+
text: "◀ Prev",
|
|
2288
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${prev.token}`,
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
if (projects.page + 1 < projects.totalPages) {
|
|
2292
|
+
const next = await this.store.putCallback({
|
|
2293
|
+
kind: "picker-view",
|
|
2294
|
+
conversation,
|
|
2295
|
+
view: {
|
|
2296
|
+
mode: "projects",
|
|
2297
|
+
includeAll: true,
|
|
2298
|
+
syncTopic: parsed.syncTopic,
|
|
2299
|
+
workspaceDir: parsed.cwd,
|
|
2300
|
+
query: parsed.query || undefined,
|
|
2301
|
+
page: projects.page + 1,
|
|
2302
|
+
},
|
|
2303
|
+
});
|
|
2304
|
+
navRow.push({
|
|
2305
|
+
text: "Next ▶",
|
|
2306
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${next.token}`,
|
|
2307
|
+
});
|
|
2308
|
+
}
|
|
2309
|
+
if (navRow.length > 0) {
|
|
2310
|
+
buttons.push(navRow);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
const recent = await this.store.putCallback({
|
|
2315
|
+
kind: "picker-view",
|
|
2316
|
+
conversation,
|
|
2317
|
+
view: {
|
|
2318
|
+
mode: "threads",
|
|
2319
|
+
includeAll: true,
|
|
2320
|
+
syncTopic: parsed.syncTopic,
|
|
2321
|
+
workspaceDir: parsed.cwd,
|
|
2322
|
+
page: 0,
|
|
2323
|
+
},
|
|
2324
|
+
});
|
|
2325
|
+
buttons.push([
|
|
2326
|
+
{
|
|
2327
|
+
text: "Recent Sessions",
|
|
2328
|
+
callback_data: `${INTERACTIVE_NAMESPACE}:${recent.token}`,
|
|
2329
|
+
},
|
|
2330
|
+
]);
|
|
2331
|
+
|
|
2332
|
+
return {
|
|
2333
|
+
text: formatProjectPickerIntro({
|
|
2334
|
+
page: projects.page,
|
|
2335
|
+
totalPages: projects.totalPages,
|
|
2336
|
+
totalItems: projects.totalItems,
|
|
2337
|
+
workspaceDir,
|
|
2338
|
+
}),
|
|
2339
|
+
buttons,
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
private async sendDiscordPicker(
|
|
2344
|
+
conversation: ConversationTarget,
|
|
2345
|
+
picker: PickerRender,
|
|
2346
|
+
): Promise<void> {
|
|
2347
|
+
this.api.logger.debug(
|
|
2348
|
+
`codex discord picker send conversation=${conversation.conversationId} rows=${picker.buttons?.length ?? 0}`,
|
|
2349
|
+
);
|
|
2350
|
+
await this.api.runtime.channel.discord.sendComponentMessage(
|
|
2351
|
+
conversation.conversationId,
|
|
2352
|
+
{
|
|
2353
|
+
text: picker.text,
|
|
2354
|
+
blocks: (picker.buttons ?? []).map((row) => ({
|
|
2355
|
+
type: "actions" as const,
|
|
2356
|
+
buttons: row.map((button) => ({
|
|
2357
|
+
label: truncateDiscordLabel(button.text),
|
|
2358
|
+
style: "primary" as const,
|
|
2359
|
+
callbackData: button.callback_data,
|
|
2360
|
+
})),
|
|
2361
|
+
})),
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
accountId: conversation.accountId,
|
|
2365
|
+
},
|
|
2366
|
+
);
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
private async dispatchCallbackAction(
|
|
2370
|
+
callback: CallbackAction,
|
|
2371
|
+
responders: PickerResponders,
|
|
2372
|
+
): Promise<void> {
|
|
2373
|
+
if (callback.kind === "resume-thread") {
|
|
2374
|
+
await responders.clear().catch(() => undefined);
|
|
2375
|
+
const threadState = await this.client
|
|
2376
|
+
.readThreadState({
|
|
2377
|
+
sessionKey: buildPluginSessionKey(callback.threadId),
|
|
2378
|
+
threadId: callback.threadId,
|
|
2379
|
+
})
|
|
2380
|
+
.catch(() => undefined);
|
|
2381
|
+
const bindResult = await this.requestConversationBinding(
|
|
2382
|
+
callback.conversation,
|
|
2383
|
+
{
|
|
2384
|
+
threadId: callback.threadId,
|
|
2385
|
+
workspaceDir: threadState?.cwd?.trim() || callback.workspaceDir,
|
|
2386
|
+
threadTitle: threadState?.threadName,
|
|
2387
|
+
},
|
|
2388
|
+
responders.requestConversationBinding,
|
|
2389
|
+
);
|
|
2390
|
+
if (bindResult.status === "pending") {
|
|
2391
|
+
await responders.reply(bindResult.reply.text ?? "Bind approval requested.");
|
|
2392
|
+
return;
|
|
2393
|
+
}
|
|
2394
|
+
if (bindResult.status === "error") {
|
|
2395
|
+
await responders.reply(bindResult.message);
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
await this.store.removeCallback(callback.token);
|
|
2399
|
+
if (callback.syncTopic) {
|
|
2400
|
+
const syncedName = buildResumeTopicName({
|
|
2401
|
+
title: threadState?.threadName,
|
|
2402
|
+
projectKey: threadState?.cwd?.trim() || callback.workspaceDir,
|
|
2403
|
+
threadId: callback.threadId,
|
|
2404
|
+
});
|
|
2405
|
+
if (syncedName) {
|
|
2406
|
+
await this.renameConversationIfSupported(responders.conversation, syncedName);
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
await this.sendBoundConversationSummary(callback.conversation);
|
|
2410
|
+
return;
|
|
2411
|
+
}
|
|
2412
|
+
if (callback.kind === "pending-input") {
|
|
2413
|
+
await responders.clear().catch(() => undefined);
|
|
2414
|
+
const pending = this.store.getPendingRequestById(callback.requestId);
|
|
2415
|
+
if (!pending || pending.state.expiresAt <= Date.now()) {
|
|
2416
|
+
await this.store.removeCallback(callback.token);
|
|
2417
|
+
await responders.reply("That Codex request expired. Please retry.");
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
const active = this.activeRuns.get(buildConversationKey(callback.conversation));
|
|
2421
|
+
if (!active) {
|
|
2422
|
+
await responders.reply("No active Codex run is waiting for input.");
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const submitted = await active.handle.submitPendingInput(callback.actionIndex);
|
|
2426
|
+
if (!submitted) {
|
|
2427
|
+
await responders.reply("That Codex action is no longer available.");
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
await this.store.removeCallback(callback.token);
|
|
2431
|
+
await responders.reply("Sent to Codex.");
|
|
2432
|
+
return;
|
|
2433
|
+
}
|
|
2434
|
+
if (callback.kind === "pending-questionnaire") {
|
|
2435
|
+
const pending = this.store.getPendingRequestById(callback.requestId);
|
|
2436
|
+
if (!pending || pending.state.expiresAt <= Date.now() || !pending.state.questionnaire) {
|
|
2437
|
+
await this.store.removeCallback(callback.token);
|
|
2438
|
+
await responders.reply("That Codex questionnaire expired. Please retry.");
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const active = this.activeRuns.get(buildConversationKey(callback.conversation));
|
|
2442
|
+
if (!active) {
|
|
2443
|
+
await responders.reply("No active Codex run is waiting for input.");
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
const questionnaire = pending.state.questionnaire;
|
|
2447
|
+
if (callback.action === "freeform") {
|
|
2448
|
+
questionnaire.currentIndex = Math.max(
|
|
2449
|
+
0,
|
|
2450
|
+
Math.min(callback.questionIndex, questionnaire.questions.length - 1),
|
|
2451
|
+
);
|
|
2452
|
+
questionnaire.awaitingFreeform = true;
|
|
2453
|
+
pending.updatedAt = Date.now();
|
|
2454
|
+
await this.store.upsertPendingRequest(pending);
|
|
2455
|
+
await responders.reply(
|
|
2456
|
+
`Send a free-form answer for question ${questionnaire.currentIndex + 1} of ${questionnaire.questions.length} and I’ll record it.`,
|
|
2457
|
+
);
|
|
2458
|
+
await this.sendPendingQuestionnaire(callback.conversation, pending.state, {
|
|
2459
|
+
editMessage: async (text, buttons) => {
|
|
2460
|
+
await responders.editPicker({ text, buttons });
|
|
2461
|
+
},
|
|
2462
|
+
});
|
|
2463
|
+
return;
|
|
2464
|
+
}
|
|
2465
|
+
if (callback.action === "prev") {
|
|
2466
|
+
questionnaire.currentIndex = Math.max(0, callback.questionIndex - 1);
|
|
2467
|
+
questionnaire.awaitingFreeform = false;
|
|
2468
|
+
pending.updatedAt = Date.now();
|
|
2469
|
+
await this.store.upsertPendingRequest(pending);
|
|
2470
|
+
await this.sendPendingQuestionnaire(callback.conversation, pending.state, {
|
|
2471
|
+
editMessage: async (text, buttons) => {
|
|
2472
|
+
await responders.editPicker({ text, buttons });
|
|
2473
|
+
},
|
|
2474
|
+
});
|
|
2475
|
+
return;
|
|
2476
|
+
}
|
|
2477
|
+
if (callback.action === "next") {
|
|
2478
|
+
const currentAnswer = questionnaire.answers[callback.questionIndex];
|
|
2479
|
+
if (!currentAnswer) {
|
|
2480
|
+
await responders.reply("Answer this question first, or choose Free Form.");
|
|
2481
|
+
return;
|
|
2482
|
+
}
|
|
2483
|
+
questionnaire.currentIndex = Math.min(
|
|
2484
|
+
questionnaire.questions.length - 1,
|
|
2485
|
+
callback.questionIndex + 1,
|
|
2486
|
+
);
|
|
2487
|
+
questionnaire.awaitingFreeform = false;
|
|
2488
|
+
pending.updatedAt = Date.now();
|
|
2489
|
+
await this.store.upsertPendingRequest(pending);
|
|
2490
|
+
await this.sendPendingQuestionnaire(callback.conversation, pending.state, {
|
|
2491
|
+
editMessage: async (text, buttons) => {
|
|
2492
|
+
await responders.editPicker({ text, buttons });
|
|
2493
|
+
},
|
|
2494
|
+
});
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
const question = questionnaire.questions[callback.questionIndex];
|
|
2498
|
+
const option = question?.options[callback.optionIndex ?? -1];
|
|
2499
|
+
if (!question || !option) {
|
|
2500
|
+
await responders.reply("That Codex option is no longer available.");
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
questionnaire.answers[callback.questionIndex] = {
|
|
2504
|
+
kind: "option",
|
|
2505
|
+
optionKey: option.key,
|
|
2506
|
+
optionLabel: option.label,
|
|
2507
|
+
};
|
|
2508
|
+
questionnaire.awaitingFreeform = false;
|
|
2509
|
+
questionnaire.currentIndex = Math.min(
|
|
2510
|
+
questionnaire.questions.length - 1,
|
|
2511
|
+
callback.questionIndex + 1,
|
|
2512
|
+
);
|
|
2513
|
+
pending.updatedAt = Date.now();
|
|
2514
|
+
await this.store.upsertPendingRequest(pending);
|
|
2515
|
+
if (questionnaireIsComplete(questionnaire)) {
|
|
2516
|
+
const submitted = await active.handle.submitPendingInputPayload(
|
|
2517
|
+
buildPendingQuestionnaireResponse(questionnaire),
|
|
2518
|
+
);
|
|
2519
|
+
if (!submitted) {
|
|
2520
|
+
await responders.reply("That Codex questionnaire is no longer accepting answers.");
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
await responders.clear().catch(() => undefined);
|
|
2524
|
+
await this.store.removePendingRequest(pending.requestId);
|
|
2525
|
+
await responders.reply("Recorded your answers and sent them to Codex.");
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
await this.sendPendingQuestionnaire(callback.conversation, pending.state, {
|
|
2529
|
+
editMessage: async (text, buttons) => {
|
|
2530
|
+
await responders.editPicker({ text, buttons });
|
|
2531
|
+
},
|
|
2532
|
+
});
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
if (callback.kind === "run-prompt") {
|
|
2536
|
+
await responders.clear().catch(() => undefined);
|
|
2537
|
+
const binding = this.store.getBinding(callback.conversation);
|
|
2538
|
+
const conversation = {
|
|
2539
|
+
...callback.conversation,
|
|
2540
|
+
threadId: responders.conversation.threadId,
|
|
2541
|
+
};
|
|
2542
|
+
const workspaceDir = callback.workspaceDir?.trim() || binding?.workspaceDir || resolveWorkspaceDir({
|
|
2543
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
2544
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
2545
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
2546
|
+
});
|
|
2547
|
+
await this.store.removeCallback(callback.token);
|
|
2548
|
+
const active = this.activeRuns.get(buildConversationKey(conversation));
|
|
2549
|
+
const ackText = this.buildRunPromptAckText(callback.prompt);
|
|
2550
|
+
if (active) {
|
|
2551
|
+
if (active.mode === "plan" && (callback.collaborationMode?.mode ?? "default") !== "plan") {
|
|
2552
|
+
this.activeRuns.delete(buildConversationKey(conversation));
|
|
2553
|
+
await active.handle.interrupt().catch(() => undefined);
|
|
2554
|
+
} else {
|
|
2555
|
+
const handled = await active.handle.queueMessage(callback.prompt);
|
|
2556
|
+
if (handled) {
|
|
2557
|
+
await responders.reply(ackText);
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
await this.startTurn({
|
|
2563
|
+
conversation,
|
|
2564
|
+
binding,
|
|
2565
|
+
workspaceDir,
|
|
2566
|
+
prompt: callback.prompt,
|
|
2567
|
+
reason: "command",
|
|
2568
|
+
collaborationMode: callback.collaborationMode,
|
|
2569
|
+
});
|
|
2570
|
+
await responders.reply(ackText);
|
|
2571
|
+
return;
|
|
2572
|
+
}
|
|
2573
|
+
if (callback.kind === "set-model") {
|
|
2574
|
+
await responders.clear().catch(() => undefined);
|
|
2575
|
+
const binding = this.store.getBinding(callback.conversation);
|
|
2576
|
+
await this.store.removeCallback(callback.token);
|
|
2577
|
+
if (!binding) {
|
|
2578
|
+
await responders.reply("No Codex binding for this conversation.");
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
const state = await this.client.setThreadModel({
|
|
2582
|
+
sessionKey: binding.sessionKey,
|
|
2583
|
+
threadId: binding.threadId,
|
|
2584
|
+
model: callback.model,
|
|
2585
|
+
workspaceDir: binding.workspaceDir,
|
|
2586
|
+
});
|
|
2587
|
+
await responders.reply(`Codex model set to ${state.model || callback.model}.`);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
if (callback.kind === "reply-text") {
|
|
2591
|
+
await responders.clear().catch(() => undefined);
|
|
2592
|
+
await this.store.removeCallback(callback.token);
|
|
2593
|
+
await responders.reply(callback.text);
|
|
2594
|
+
return;
|
|
2595
|
+
}
|
|
2596
|
+
if (callback.kind === "rename-thread") {
|
|
2597
|
+
await responders.clear().catch(() => undefined);
|
|
2598
|
+
const binding = this.store.getBinding(callback.conversation);
|
|
2599
|
+
await this.store.removeCallback(callback.token);
|
|
2600
|
+
if (!binding) {
|
|
2601
|
+
await responders.reply("Bind this conversation to a Codex thread before renaming it.");
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
try {
|
|
2605
|
+
const name = await this.applyRenameStyle(
|
|
2606
|
+
responders.conversation,
|
|
2607
|
+
binding,
|
|
2608
|
+
callback.style,
|
|
2609
|
+
callback.syncTopic,
|
|
2610
|
+
);
|
|
2611
|
+
await responders.reply(`Renamed the Codex thread to "${name}".`);
|
|
2612
|
+
} catch (error) {
|
|
2613
|
+
await responders.reply(
|
|
2614
|
+
error instanceof Error ? error.message : "Unable to derive a Codex thread name.",
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
return;
|
|
2618
|
+
}
|
|
2619
|
+
const binding = this.store.getBinding(callback.conversation);
|
|
2620
|
+
await this.store.removeCallback(callback.token);
|
|
2621
|
+
const parsed = {
|
|
2622
|
+
includeAll: callback.view.includeAll,
|
|
2623
|
+
listProjects: callback.view.mode === "projects",
|
|
2624
|
+
syncTopic: callback.view.syncTopic ?? false,
|
|
2625
|
+
cwd: callback.view.workspaceDir,
|
|
2626
|
+
query: callback.view.query ?? "",
|
|
2627
|
+
};
|
|
2628
|
+
const picker =
|
|
2629
|
+
callback.view.mode === "projects"
|
|
2630
|
+
? await this.renderProjectPicker(responders.conversation, binding, parsed, callback.view.page)
|
|
2631
|
+
: await this.renderThreadPicker(
|
|
2632
|
+
responders.conversation,
|
|
2633
|
+
binding,
|
|
2634
|
+
parsed,
|
|
2635
|
+
callback.view.page,
|
|
2636
|
+
callback.view.projectName,
|
|
2637
|
+
);
|
|
2638
|
+
await responders.editPicker(picker);
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
private async resolveSingleThread(
|
|
2642
|
+
sessionKey: string | undefined,
|
|
2643
|
+
workspaceDir: string | undefined,
|
|
2644
|
+
filter: string,
|
|
2645
|
+
): Promise<
|
|
2646
|
+
| { kind: "none" }
|
|
2647
|
+
| { kind: "unique"; thread: { threadId: string; title?: string; projectKey?: string } }
|
|
2648
|
+
| { kind: "ambiguous"; threads: Array<{ threadId: string; title?: string; projectKey?: string }> }
|
|
2649
|
+
> {
|
|
2650
|
+
const trimmed = filter.trim();
|
|
2651
|
+
const threads = await this.client.listThreads({
|
|
2652
|
+
sessionKey,
|
|
2653
|
+
workspaceDir,
|
|
2654
|
+
filter: trimmed,
|
|
2655
|
+
});
|
|
2656
|
+
return selectThreadFromMatches(threads, trimmed);
|
|
2657
|
+
}
|
|
2658
|
+
|
|
2659
|
+
private async bindConversation(
|
|
2660
|
+
conversation: ConversationTarget,
|
|
2661
|
+
params: {
|
|
2662
|
+
threadId: string;
|
|
2663
|
+
workspaceDir: string;
|
|
2664
|
+
threadTitle?: string;
|
|
2665
|
+
},
|
|
2666
|
+
): Promise<StoredBinding> {
|
|
2667
|
+
const sessionKey = buildPluginSessionKey(params.threadId);
|
|
2668
|
+
const record: StoredBinding = {
|
|
2669
|
+
conversation: {
|
|
2670
|
+
channel: conversation.channel,
|
|
2671
|
+
accountId: conversation.accountId,
|
|
2672
|
+
conversationId: conversation.conversationId,
|
|
2673
|
+
parentConversationId: conversation.parentConversationId,
|
|
2674
|
+
},
|
|
2675
|
+
sessionKey,
|
|
2676
|
+
threadId: params.threadId,
|
|
2677
|
+
workspaceDir: params.workspaceDir,
|
|
2678
|
+
threadTitle: params.threadTitle,
|
|
2679
|
+
contextUsage: this.store.getBinding(conversation)?.contextUsage,
|
|
2680
|
+
updatedAt: Date.now(),
|
|
2681
|
+
};
|
|
2682
|
+
await this.store.upsertBinding(record);
|
|
2683
|
+
return record;
|
|
2684
|
+
}
|
|
2685
|
+
|
|
2686
|
+
private async hydrateApprovedBinding(
|
|
2687
|
+
conversation: ConversationTarget,
|
|
2688
|
+
): Promise<StoredBinding | null> {
|
|
2689
|
+
const existing = this.store.getBinding(conversation);
|
|
2690
|
+
if (existing) {
|
|
2691
|
+
return existing;
|
|
2692
|
+
}
|
|
2693
|
+
const pending = this.store.getPendingBind(conversation);
|
|
2694
|
+
if (!pending) {
|
|
2695
|
+
return null;
|
|
2696
|
+
}
|
|
2697
|
+
const binding = await this.bindConversation(conversation, {
|
|
2698
|
+
threadId: pending.threadId,
|
|
2699
|
+
workspaceDir: pending.workspaceDir,
|
|
2700
|
+
threadTitle: pending.threadTitle,
|
|
2701
|
+
});
|
|
2702
|
+
await this.store.removePendingBind(conversation);
|
|
2703
|
+
return binding;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
private async requestConversationBinding(
|
|
2707
|
+
conversation: ConversationTarget,
|
|
2708
|
+
params: {
|
|
2709
|
+
threadId: string;
|
|
2710
|
+
workspaceDir: string;
|
|
2711
|
+
threadTitle?: string;
|
|
2712
|
+
},
|
|
2713
|
+
requestBinding?: (
|
|
2714
|
+
params?: { summary?: string },
|
|
2715
|
+
) => Promise<
|
|
2716
|
+
| { status: "bound" }
|
|
2717
|
+
| { status: "pending"; reply: ReplyPayload }
|
|
2718
|
+
| { status: "error"; message: string }
|
|
2719
|
+
>,
|
|
2720
|
+
): Promise<
|
|
2721
|
+
| { status: "bound"; binding: StoredBinding }
|
|
2722
|
+
| { status: "pending"; reply: ReplyPayload }
|
|
2723
|
+
| { status: "error"; message: string }
|
|
2724
|
+
> {
|
|
2725
|
+
if (!requestBinding) {
|
|
2726
|
+
return {
|
|
2727
|
+
status: "error",
|
|
2728
|
+
message: "This action can only bind from a live command or interactive context.",
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
const approval = await requestBinding({
|
|
2732
|
+
summary: `Bind this conversation to Codex thread ${params.threadTitle?.trim() || params.threadId}.`,
|
|
2733
|
+
});
|
|
2734
|
+
if (approval.status !== "bound") {
|
|
2735
|
+
if (approval.status === "pending") {
|
|
2736
|
+
await this.store.upsertPendingBind({
|
|
2737
|
+
conversation: {
|
|
2738
|
+
channel: conversation.channel,
|
|
2739
|
+
accountId: conversation.accountId,
|
|
2740
|
+
conversationId: conversation.conversationId,
|
|
2741
|
+
parentConversationId: conversation.parentConversationId,
|
|
2742
|
+
},
|
|
2743
|
+
threadId: params.threadId,
|
|
2744
|
+
workspaceDir: params.workspaceDir,
|
|
2745
|
+
threadTitle: params.threadTitle,
|
|
2746
|
+
updatedAt: Date.now(),
|
|
2747
|
+
});
|
|
2748
|
+
}
|
|
2749
|
+
return approval;
|
|
2750
|
+
}
|
|
2751
|
+
const binding = await this.bindConversation(conversation, params);
|
|
2752
|
+
return { status: "bound", binding };
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
private trimReplayText(value?: string, maxLength = 1200): string | undefined {
|
|
2756
|
+
const trimmed = value?.trim();
|
|
2757
|
+
if (!trimmed) {
|
|
2758
|
+
return undefined;
|
|
2759
|
+
}
|
|
2760
|
+
if (trimmed.length <= maxLength) {
|
|
2761
|
+
return trimmed;
|
|
2762
|
+
}
|
|
2763
|
+
return `${trimmed.slice(0, maxLength - 3).trimEnd()}...`;
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
private isWorktreePath(projectKey?: string): boolean {
|
|
2767
|
+
const trimmed = projectKey?.trim();
|
|
2768
|
+
return Boolean(trimmed && /[/\\]worktrees[/\\][^/\\]+[/\\][^/\\]+/.test(trimmed));
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
private readThreadHasChanges(projectKey?: string): Promise<boolean | undefined> {
|
|
2772
|
+
const cwd = projectKey?.trim();
|
|
2773
|
+
if (!cwd) {
|
|
2774
|
+
return Promise.resolve(undefined);
|
|
2775
|
+
}
|
|
2776
|
+
let cached = this.threadChangesCache.get(cwd);
|
|
2777
|
+
if (!cached) {
|
|
2778
|
+
cached = execFileAsync("git", ["-C", cwd, "status", "--porcelain"], {
|
|
2779
|
+
timeout: 5_000,
|
|
2780
|
+
})
|
|
2781
|
+
.then((result) => result.stdout.trim().length > 0)
|
|
2782
|
+
.catch(() => undefined);
|
|
2783
|
+
this.threadChangesCache.set(cwd, cached);
|
|
2784
|
+
}
|
|
2785
|
+
return cached;
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
private async buildBoundConversationMessages(
|
|
2789
|
+
conversation: ConversationTarget | ConversationRef,
|
|
2790
|
+
): Promise<string[]> {
|
|
2791
|
+
const binding = this.store.getBinding({
|
|
2792
|
+
channel: conversation.channel,
|
|
2793
|
+
accountId: conversation.accountId,
|
|
2794
|
+
conversationId: conversation.conversationId,
|
|
2795
|
+
parentConversationId: conversation.parentConversationId,
|
|
2796
|
+
});
|
|
2797
|
+
if (!binding) {
|
|
2798
|
+
return ["No Codex binding for this conversation."];
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
const [state, replay] = await Promise.all([
|
|
2802
|
+
this.client.readThreadState({
|
|
2803
|
+
sessionKey: binding.sessionKey,
|
|
2804
|
+
threadId: binding.threadId,
|
|
2805
|
+
}),
|
|
2806
|
+
this.client.readThreadContext({
|
|
2807
|
+
sessionKey: binding.sessionKey,
|
|
2808
|
+
threadId: binding.threadId,
|
|
2809
|
+
}).catch(() => ({ lastUserMessage: undefined, lastAssistantMessage: undefined })),
|
|
2810
|
+
]);
|
|
2811
|
+
|
|
2812
|
+
const nextBinding =
|
|
2813
|
+
(state.threadName && state.threadName !== binding.threadTitle) ||
|
|
2814
|
+
(state.cwd?.trim() && state.cwd.trim() !== binding.workspaceDir)
|
|
2815
|
+
? {
|
|
2816
|
+
...binding,
|
|
2817
|
+
threadTitle: state.threadName?.trim() || binding.threadTitle,
|
|
2818
|
+
workspaceDir: state.cwd?.trim() || binding.workspaceDir,
|
|
2819
|
+
contextUsage: binding.contextUsage,
|
|
2820
|
+
updatedAt: Date.now(),
|
|
2821
|
+
}
|
|
2822
|
+
: binding;
|
|
2823
|
+
|
|
2824
|
+
if (nextBinding !== binding) {
|
|
2825
|
+
await this.store.upsertBinding(nextBinding);
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
const messages = [
|
|
2829
|
+
formatBoundThreadSummary({
|
|
2830
|
+
binding: nextBinding,
|
|
2831
|
+
state,
|
|
2832
|
+
}),
|
|
2833
|
+
];
|
|
2834
|
+
|
|
2835
|
+
const lastUser = this.trimReplayText(replay.lastUserMessage);
|
|
2836
|
+
if (lastUser) {
|
|
2837
|
+
messages.push("Last User Request in Thread:");
|
|
2838
|
+
messages.push(lastUser);
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
const lastAssistant = this.trimReplayText(replay.lastAssistantMessage);
|
|
2842
|
+
if (lastAssistant) {
|
|
2843
|
+
messages.push("Last Agent Reply in Thread:");
|
|
2844
|
+
messages.push(lastAssistant);
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
return messages;
|
|
2848
|
+
}
|
|
2849
|
+
|
|
2850
|
+
private async sendBoundConversationSummary(
|
|
2851
|
+
conversation: ConversationTarget | ConversationRef,
|
|
2852
|
+
): Promise<void> {
|
|
2853
|
+
const messages = await this.buildBoundConversationMessages(conversation);
|
|
2854
|
+
const target: ConversationTarget = {
|
|
2855
|
+
channel: conversation.channel,
|
|
2856
|
+
accountId: conversation.accountId,
|
|
2857
|
+
conversationId: conversation.conversationId,
|
|
2858
|
+
parentConversationId: conversation.parentConversationId,
|
|
2859
|
+
threadId: "threadId" in conversation ? conversation.threadId : undefined,
|
|
2860
|
+
};
|
|
2861
|
+
for (const message of messages) {
|
|
2862
|
+
await this.sendText(target, message);
|
|
2863
|
+
}
|
|
2864
|
+
}
|
|
2865
|
+
|
|
2866
|
+
private async buildBoundConversationSummaryReply(
|
|
2867
|
+
conversation: ConversationTarget | ConversationRef,
|
|
2868
|
+
): Promise<FollowUpSummary> {
|
|
2869
|
+
const messages = await this.buildBoundConversationMessages(conversation);
|
|
2870
|
+
const [firstMessage, ...followUps] = messages;
|
|
2871
|
+
return {
|
|
2872
|
+
initialReply: buildPlainReply(firstMessage ?? "Codex thread bound."),
|
|
2873
|
+
followUps,
|
|
2874
|
+
};
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
private queueFollowUpTexts(conversation: ConversationTarget, texts: string[]): void {
|
|
2878
|
+
if (texts.length === 0) {
|
|
2879
|
+
return;
|
|
2880
|
+
}
|
|
2881
|
+
setTimeout(() => {
|
|
2882
|
+
void (async () => {
|
|
2883
|
+
for (const text of texts) {
|
|
2884
|
+
await this.sendText(conversation, text);
|
|
2885
|
+
}
|
|
2886
|
+
})().catch((error) => {
|
|
2887
|
+
this.api.logger.warn(`codex follow-up send failed: ${String(error)}`);
|
|
2888
|
+
});
|
|
2889
|
+
}, 0);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
private async buildStatusText(
|
|
2893
|
+
conversation: ConversationTarget | null,
|
|
2894
|
+
binding: StoredBinding | null,
|
|
2895
|
+
bindingActive: boolean,
|
|
2896
|
+
): Promise<string> {
|
|
2897
|
+
const activeRun =
|
|
2898
|
+
bindingActive && conversation
|
|
2899
|
+
? this.activeRuns.get(buildConversationKey(conversation))
|
|
2900
|
+
: undefined;
|
|
2901
|
+
const workspaceDir = resolveWorkspaceDir({
|
|
2902
|
+
bindingWorkspaceDir: binding?.workspaceDir,
|
|
2903
|
+
configuredWorkspaceDir: this.settings.defaultWorkspaceDir,
|
|
2904
|
+
serviceWorkspaceDir: this.serviceWorkspaceDir,
|
|
2905
|
+
});
|
|
2906
|
+
const [threadState, account, limits, projectFolder] = await Promise.all([
|
|
2907
|
+
binding
|
|
2908
|
+
? this.client.readThreadState({
|
|
2909
|
+
sessionKey: binding.sessionKey,
|
|
2910
|
+
threadId: binding.threadId,
|
|
2911
|
+
}).catch(() => undefined)
|
|
2912
|
+
: Promise.resolve(undefined),
|
|
2913
|
+
this.client.readAccount({
|
|
2914
|
+
sessionKey: binding?.sessionKey,
|
|
2915
|
+
}).catch(() => null),
|
|
2916
|
+
this.client.readRateLimits({
|
|
2917
|
+
sessionKey: binding?.sessionKey,
|
|
2918
|
+
}).catch(() => []),
|
|
2919
|
+
this.resolveProjectFolder(binding?.workspaceDir || workspaceDir),
|
|
2920
|
+
]);
|
|
2921
|
+
|
|
2922
|
+
return formatCodexStatusText({
|
|
2923
|
+
threadState,
|
|
2924
|
+
account,
|
|
2925
|
+
rateLimits: limits,
|
|
2926
|
+
bindingActive,
|
|
2927
|
+
projectFolder,
|
|
2928
|
+
worktreeFolder: threadState?.cwd?.trim() || binding?.workspaceDir || workspaceDir,
|
|
2929
|
+
contextUsage: binding?.contextUsage,
|
|
2930
|
+
planMode: bindingActive ? activeRun?.mode === "plan" : undefined,
|
|
2931
|
+
});
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
private buildCompactStartText(usage?: StoredBinding["contextUsage"]): string {
|
|
2935
|
+
const lines = ["Starting Codex thread compaction."];
|
|
2936
|
+
const initialUsageText = usage ? formatContextUsageText(usage) : undefined;
|
|
2937
|
+
if (initialUsageText) {
|
|
2938
|
+
lines.push(`Starting context usage: ${initialUsageText}`);
|
|
2939
|
+
}
|
|
2940
|
+
lines.push("I’ll report progress here as compaction events arrive.");
|
|
2941
|
+
return lines.join("\n");
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
private async buildPlanDelivery(
|
|
2945
|
+
plan: NonNullable<import("./types.js").TurnResult["planArtifact"]>,
|
|
2946
|
+
): Promise<PlanDelivery> {
|
|
2947
|
+
const inlineText = formatCodexPlanInlineText(plan);
|
|
2948
|
+
if (inlineText.length <= PLAN_INLINE_TEXT_LIMIT) {
|
|
2949
|
+
return {
|
|
2950
|
+
summaryText: inlineText,
|
|
2951
|
+
};
|
|
2952
|
+
}
|
|
2953
|
+
const tempDir = path.join(this.api.runtime.state.resolveStateDir(), "tmp");
|
|
2954
|
+
await fs.mkdir(tempDir, { recursive: true, mode: 0o700 });
|
|
2955
|
+
const attachmentPath = path.join(
|
|
2956
|
+
tempDir,
|
|
2957
|
+
`codex-plan-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}.md`,
|
|
2958
|
+
);
|
|
2959
|
+
await fs.writeFile(attachmentPath, `${plan.markdown.trim()}\n`, "utf8");
|
|
2960
|
+
return {
|
|
2961
|
+
summaryText: formatCodexPlanAttachmentSummary(plan),
|
|
2962
|
+
attachmentPath,
|
|
2963
|
+
attachmentFallbackText: formatCodexPlanAttachmentFallback(plan),
|
|
2964
|
+
};
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
private async sendReply(
|
|
2968
|
+
conversation: ConversationTarget,
|
|
2969
|
+
payload: {
|
|
2970
|
+
text?: string;
|
|
2971
|
+
buttons?: PluginInteractiveButtons;
|
|
2972
|
+
mediaUrl?: string;
|
|
2973
|
+
},
|
|
2974
|
+
): Promise<boolean> {
|
|
2975
|
+
const text = payload.text?.trim() ?? "";
|
|
2976
|
+
const hasMedia = typeof payload.mediaUrl === "string" && payload.mediaUrl.trim().length > 0;
|
|
2977
|
+
if (!text && !hasMedia) {
|
|
2978
|
+
return false;
|
|
2979
|
+
}
|
|
2980
|
+
if (isTelegramChannel(conversation.channel)) {
|
|
2981
|
+
const mediaLocalRoots = this.resolveReplyMediaLocalRoots(payload.mediaUrl);
|
|
2982
|
+
const limit = this.api.runtime.channel.text.resolveTextChunkLimit(
|
|
2983
|
+
undefined,
|
|
2984
|
+
"telegram",
|
|
2985
|
+
conversation.accountId,
|
|
2986
|
+
{ fallbackLimit: 4000 },
|
|
2987
|
+
);
|
|
2988
|
+
const chunks = text
|
|
2989
|
+
? this.api.runtime.channel.text.chunkText(text, limit).filter(Boolean)
|
|
2990
|
+
: [];
|
|
2991
|
+
if (hasMedia) {
|
|
2992
|
+
await this.api.runtime.channel.telegram.sendMessageTelegram(
|
|
2993
|
+
conversation.parentConversationId ?? conversation.conversationId,
|
|
2994
|
+
chunks[0] ?? text,
|
|
2995
|
+
{
|
|
2996
|
+
accountId: conversation.accountId,
|
|
2997
|
+
messageThreadId: conversation.threadId,
|
|
2998
|
+
mediaUrl: payload.mediaUrl,
|
|
2999
|
+
mediaLocalRoots,
|
|
3000
|
+
buttons: chunks.length <= 1 ? payload.buttons : undefined,
|
|
3001
|
+
},
|
|
3002
|
+
);
|
|
3003
|
+
for (let index = 1; index < chunks.length; index += 1) {
|
|
3004
|
+
const chunk = chunks[index];
|
|
3005
|
+
if (!chunk) {
|
|
3006
|
+
continue;
|
|
3007
|
+
}
|
|
3008
|
+
await this.api.runtime.channel.telegram.sendMessageTelegram(
|
|
3009
|
+
conversation.parentConversationId ?? conversation.conversationId,
|
|
3010
|
+
chunk,
|
|
3011
|
+
{
|
|
3012
|
+
accountId: conversation.accountId,
|
|
3013
|
+
messageThreadId: conversation.threadId,
|
|
3014
|
+
buttons: index === chunks.length - 1 ? payload.buttons : undefined,
|
|
3015
|
+
},
|
|
3016
|
+
);
|
|
3017
|
+
}
|
|
3018
|
+
return true;
|
|
3019
|
+
}
|
|
3020
|
+
const textChunks = chunks.length > 0 ? chunks : [text];
|
|
3021
|
+
for (let index = 0; index < textChunks.length; index += 1) {
|
|
3022
|
+
const chunk = textChunks[index];
|
|
3023
|
+
if (!chunk) {
|
|
3024
|
+
continue;
|
|
3025
|
+
}
|
|
3026
|
+
await this.api.runtime.channel.telegram.sendMessageTelegram(
|
|
3027
|
+
conversation.parentConversationId ?? conversation.conversationId,
|
|
3028
|
+
chunk,
|
|
3029
|
+
{
|
|
3030
|
+
accountId: conversation.accountId,
|
|
3031
|
+
messageThreadId: conversation.threadId,
|
|
3032
|
+
buttons: index === textChunks.length - 1 ? payload.buttons : undefined,
|
|
3033
|
+
},
|
|
3034
|
+
);
|
|
3035
|
+
}
|
|
3036
|
+
return true;
|
|
3037
|
+
}
|
|
3038
|
+
if (isDiscordChannel(conversation.channel)) {
|
|
3039
|
+
const mediaLocalRoots = this.resolveReplyMediaLocalRoots(payload.mediaUrl);
|
|
3040
|
+
const limit = this.api.runtime.channel.text.resolveTextChunkLimit(
|
|
3041
|
+
undefined,
|
|
3042
|
+
"discord",
|
|
3043
|
+
conversation.accountId,
|
|
3044
|
+
{ fallbackLimit: 2000 },
|
|
3045
|
+
);
|
|
3046
|
+
const chunks = text
|
|
3047
|
+
? this.api.runtime.channel.text.chunkText(text, limit).filter(Boolean)
|
|
3048
|
+
: [];
|
|
3049
|
+
if (payload.buttons && payload.buttons.length > 0) {
|
|
3050
|
+
this.api.logger.debug(
|
|
3051
|
+
`codex discord reply send conversation=${conversation.conversationId} rows=${payload.buttons.length}`,
|
|
3052
|
+
);
|
|
3053
|
+
const attachmentChunk = hasMedia ? (chunks.shift() ?? text) : undefined;
|
|
3054
|
+
if (hasMedia) {
|
|
3055
|
+
await this.api.runtime.channel.discord.sendMessageDiscord(
|
|
3056
|
+
conversation.conversationId,
|
|
3057
|
+
attachmentChunk ?? "",
|
|
3058
|
+
{
|
|
3059
|
+
accountId: conversation.accountId,
|
|
3060
|
+
mediaUrl: payload.mediaUrl,
|
|
3061
|
+
mediaLocalRoots,
|
|
3062
|
+
},
|
|
3063
|
+
);
|
|
3064
|
+
}
|
|
3065
|
+
const finalChunk = chunks.pop() ?? (hasMedia ? "" : text);
|
|
3066
|
+
for (const chunk of chunks) {
|
|
3067
|
+
await this.api.runtime.channel.discord.sendMessageDiscord(conversation.conversationId, chunk, {
|
|
3068
|
+
accountId: conversation.accountId,
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
await this.api.runtime.channel.discord.sendComponentMessage(
|
|
3072
|
+
conversation.conversationId,
|
|
3073
|
+
{
|
|
3074
|
+
text: finalChunk,
|
|
3075
|
+
blocks: payload.buttons.map((row) => ({
|
|
3076
|
+
type: "actions" as const,
|
|
3077
|
+
buttons: row.map((button) => ({
|
|
3078
|
+
label: truncateDiscordLabel(button.text),
|
|
3079
|
+
style: "primary" as const,
|
|
3080
|
+
callbackData: button.callback_data,
|
|
3081
|
+
})),
|
|
3082
|
+
})),
|
|
3083
|
+
},
|
|
3084
|
+
{
|
|
3085
|
+
accountId: conversation.accountId,
|
|
3086
|
+
},
|
|
3087
|
+
);
|
|
3088
|
+
return true;
|
|
3089
|
+
}
|
|
3090
|
+
const textChunks = chunks.length > 0 ? chunks : [text];
|
|
3091
|
+
if (hasMedia) {
|
|
3092
|
+
const firstChunk = textChunks.shift() ?? "";
|
|
3093
|
+
await this.api.runtime.channel.discord.sendMessageDiscord(
|
|
3094
|
+
conversation.conversationId,
|
|
3095
|
+
firstChunk,
|
|
3096
|
+
{
|
|
3097
|
+
accountId: conversation.accountId,
|
|
3098
|
+
mediaUrl: payload.mediaUrl,
|
|
3099
|
+
mediaLocalRoots,
|
|
3100
|
+
},
|
|
3101
|
+
);
|
|
3102
|
+
}
|
|
3103
|
+
for (const chunk of textChunks) {
|
|
3104
|
+
if (!chunk) {
|
|
3105
|
+
continue;
|
|
3106
|
+
}
|
|
3107
|
+
await this.api.runtime.channel.discord.sendMessageDiscord(conversation.conversationId, chunk, {
|
|
3108
|
+
accountId: conversation.accountId,
|
|
3109
|
+
});
|
|
3110
|
+
}
|
|
3111
|
+
return hasMedia || textChunks.length > 0;
|
|
3112
|
+
}
|
|
3113
|
+
return false;
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
private resolveReplyMediaLocalRoots(mediaUrl?: string): readonly string[] | undefined {
|
|
3117
|
+
const rawValue = mediaUrl?.trim();
|
|
3118
|
+
if (!rawValue) {
|
|
3119
|
+
return undefined;
|
|
3120
|
+
}
|
|
3121
|
+
const localPath = rawValue.startsWith("file://") ? fileURLToPath(rawValue) : rawValue;
|
|
3122
|
+
if (!path.isAbsolute(localPath)) {
|
|
3123
|
+
return undefined;
|
|
3124
|
+
}
|
|
3125
|
+
const roots = new Set<string>([this.api.runtime.state.resolveStateDir(), path.dirname(localPath)]);
|
|
3126
|
+
return [...roots];
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
private buildRunPromptAckText(prompt: string): string {
|
|
3130
|
+
const trimmed = prompt.trim();
|
|
3131
|
+
if (trimmed === "Implement the plan.") {
|
|
3132
|
+
return "Sent the plan to Codex.";
|
|
3133
|
+
}
|
|
3134
|
+
return trimmed.length > 160 ? "Sent the prompt to Codex." : `Sent ${trimmed} to Codex.`;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
private async resolveProjectFolder(worktreeFolder?: string): Promise<string | undefined> {
|
|
3138
|
+
const cwd = worktreeFolder?.trim();
|
|
3139
|
+
if (!cwd) {
|
|
3140
|
+
return undefined;
|
|
3141
|
+
}
|
|
3142
|
+
try {
|
|
3143
|
+
const result = await execFileAsync(
|
|
3144
|
+
"git",
|
|
3145
|
+
["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
3146
|
+
{ timeout: 5_000 },
|
|
3147
|
+
);
|
|
3148
|
+
const commonDir = result.stdout.trim();
|
|
3149
|
+
if (!commonDir) {
|
|
3150
|
+
return cwd;
|
|
3151
|
+
}
|
|
3152
|
+
return path.dirname(commonDir);
|
|
3153
|
+
} catch {
|
|
3154
|
+
return cwd;
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
private async unbindConversation(conversation: ConversationTarget): Promise<void> {
|
|
3159
|
+
await this.store.removeBinding(conversation);
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
private async reconcileBindings(): Promise<void> {
|
|
3163
|
+
return;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
private async startTypingLease(conversation: ConversationTarget): Promise<{
|
|
3167
|
+
stop: () => void;
|
|
3168
|
+
} | null> {
|
|
3169
|
+
if (isTelegramChannel(conversation.channel)) {
|
|
3170
|
+
return await this.api.runtime.channel.telegram.typing.start({
|
|
3171
|
+
to: conversation.parentConversationId ?? conversation.conversationId,
|
|
3172
|
+
accountId: conversation.accountId,
|
|
3173
|
+
messageThreadId: conversation.threadId,
|
|
3174
|
+
});
|
|
3175
|
+
}
|
|
3176
|
+
if (isDiscordChannel(conversation.channel)) {
|
|
3177
|
+
if (conversation.conversationId.startsWith("user:")) {
|
|
3178
|
+
return null;
|
|
3179
|
+
}
|
|
3180
|
+
const channelId =
|
|
3181
|
+
denormalizeDiscordConversationId(conversation.conversationId) ?? conversation.conversationId;
|
|
3182
|
+
return await this.api.runtime.channel.discord.typing.start({
|
|
3183
|
+
channelId,
|
|
3184
|
+
accountId: conversation.accountId,
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
return null;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
private async sendText(
|
|
3191
|
+
conversation: ConversationTarget,
|
|
3192
|
+
text: string,
|
|
3193
|
+
opts?: { buttons?: PluginInteractiveButtons },
|
|
3194
|
+
): Promise<void> {
|
|
3195
|
+
await this.sendReply(conversation, {
|
|
3196
|
+
text,
|
|
3197
|
+
buttons: opts?.buttons,
|
|
3198
|
+
});
|
|
3199
|
+
}
|
|
3200
|
+
|
|
3201
|
+
private async renameConversationIfSupported(
|
|
3202
|
+
conversation: ConversationTarget,
|
|
3203
|
+
name: string,
|
|
3204
|
+
): Promise<void> {
|
|
3205
|
+
if (isTelegramChannel(conversation.channel) && conversation.threadId != null) {
|
|
3206
|
+
await this.api.runtime.channel.telegram.conversationActions.renameTopic(
|
|
3207
|
+
conversation.parentConversationId ?? conversation.conversationId,
|
|
3208
|
+
conversation.threadId,
|
|
3209
|
+
name,
|
|
3210
|
+
{
|
|
3211
|
+
accountId: conversation.accountId,
|
|
3212
|
+
},
|
|
3213
|
+
).catch((error) => {
|
|
3214
|
+
this.api.logger.warn(`codex telegram topic rename failed: ${String(error)}`);
|
|
3215
|
+
});
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
if (isDiscordChannel(conversation.channel)) {
|
|
3219
|
+
await this.api.runtime.channel.discord.conversationActions.editChannel(
|
|
3220
|
+
conversation.conversationId,
|
|
3221
|
+
{
|
|
3222
|
+
name,
|
|
3223
|
+
},
|
|
3224
|
+
{
|
|
3225
|
+
accountId: conversation.accountId,
|
|
3226
|
+
},
|
|
3227
|
+
).catch((error) => {
|
|
3228
|
+
this.api.logger.warn(`codex discord channel rename failed: ${String(error)}`);
|
|
3229
|
+
});
|
|
3230
|
+
}
|
|
3231
|
+
}
|
|
3232
|
+
}
|