telecodex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,131 @@
1
+ import { presetFromProfile } from "../../config.js";
2
+ import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
3
+ import { refreshSessionIfActiveTurnIsStale } from "../inputService.js";
4
+ import { contextLogFields, formatHelpText, formatPrivateStatus, formatProjectStatus, formatReasoningEffort, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, parseSubcommand, } from "../commandSupport.js";
5
+ import { formatIsoTimestamp, sessionLogFields } from "../sessionFlow.js";
6
+ export function registerOperationalHandlers(deps) {
7
+ const { bot, config, store, projects, codex, logger } = deps;
8
+ bot.command(["start", "help"], async (ctx) => {
9
+ await ctx.reply(formatHelpText(ctx, projects));
10
+ });
11
+ bot.command("status", async (ctx) => {
12
+ if (isPrivateChat(ctx)) {
13
+ await ctx.reply(formatPrivateStatus(store, projects));
14
+ return;
15
+ }
16
+ const project = getProjectForContext(ctx, projects);
17
+ if (!project) {
18
+ await ctx.reply("This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.");
19
+ return;
20
+ }
21
+ if (!hasTopicContext(ctx)) {
22
+ await ctx.reply(formatProjectStatus(project));
23
+ return;
24
+ }
25
+ const session = getScopedSession(ctx, store, projects, config);
26
+ if (!session)
27
+ return;
28
+ const latestSession = await refreshSessionIfActiveTurnIsStale(session, store, codex, bot, logger);
29
+ const queueDepth = store.getQueuedInputCount(latestSession.sessionKey);
30
+ const queuedPreview = store.listQueuedInputs(latestSession.sessionKey, 3);
31
+ const activeRun = codex.getActiveRun(latestSession.sessionKey);
32
+ await ctx.reply([
33
+ "Status",
34
+ `project: ${project.name}`,
35
+ `root: ${project.cwd}`,
36
+ `thread: ${latestSession.codexThreadId ?? "not created"}`,
37
+ `state: ${formatSessionRuntimeStatus(latestSession.runtimeStatus)}`,
38
+ `state detail: ${latestSession.runtimeStatusDetail ?? "none"}`,
39
+ `state updated: ${formatIsoTimestamp(latestSession.runtimeStatusUpdatedAt)}`,
40
+ `active turn: ${latestSession.activeTurnId ?? "none"}`,
41
+ `active run: ${activeRun ? formatIsoTimestamp(activeRun.startedAt) : "none"}`,
42
+ `active run thread: ${activeRun?.threadId ?? "none"}`,
43
+ `active run last event: ${activeRun?.lastEventType ?? "none"}`,
44
+ `active run last update: ${activeRun ? formatIsoTimestamp(activeRun.lastEventAt) : "none"}`,
45
+ `queue: ${queueDepth}`,
46
+ `queue next: ${formatQueuedPreview(queuedPreview)}`,
47
+ `cwd: ${latestSession.cwd}`,
48
+ `preset: ${presetFromProfile(latestSession)}`,
49
+ `sandbox: ${latestSession.sandboxMode}`,
50
+ `approval: ${latestSession.approvalPolicy}`,
51
+ `network: ${latestSession.networkAccessEnabled ? "on" : "off"}`,
52
+ `web: ${latestSession.webSearchMode ?? "codex-default"}`,
53
+ `git check: ${latestSession.skipGitRepoCheck ? "skip" : "enforce"}`,
54
+ `add dirs: ${latestSession.additionalDirectories.length}`,
55
+ `schema: ${latestSession.outputSchema ? "set" : "none"}`,
56
+ `model: ${latestSession.model}`,
57
+ `effort: ${formatReasoningEffort(latestSession.reasoningEffort)}`,
58
+ ].join("\n"));
59
+ });
60
+ bot.command("queue", async (ctx) => {
61
+ const session = getScopedSession(ctx, store, projects, config);
62
+ if (!session)
63
+ return;
64
+ const { command, args } = parseSubcommand(ctx.match.trim());
65
+ if (!command) {
66
+ const queued = store.listQueuedInputs(session.sessionKey, 5);
67
+ const queueDepth = store.getQueuedInputCount(session.sessionKey);
68
+ await ctx.reply([
69
+ "Queue",
70
+ `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
71
+ `active turn: ${session.activeTurnId ?? "none"}`,
72
+ `queue: ${queueDepth}`,
73
+ queued.length > 0 ? `items:\n${formatQueuedItems(queued)}` : "items: none",
74
+ "Usage: /queue | /queue drop <id> | /queue clear",
75
+ ].join("\n"));
76
+ return;
77
+ }
78
+ if (command === "clear") {
79
+ const removed = store.clearQueuedInputs(session.sessionKey);
80
+ await ctx.reply(`Cleared the queue and removed ${removed} pending message(s).`);
81
+ return;
82
+ }
83
+ if (command === "drop") {
84
+ const id = Number(args);
85
+ if (!Number.isInteger(id) || id <= 0) {
86
+ await ctx.reply("Usage: /queue drop <id>");
87
+ return;
88
+ }
89
+ const removed = store.removeQueuedInputForSession(session.sessionKey, id);
90
+ await ctx.reply(removed ? `Removed queued item #${id}.` : `Queued item #${id} was not found.`);
91
+ return;
92
+ }
93
+ await ctx.reply("Usage: /queue | /queue drop <id> | /queue clear");
94
+ });
95
+ bot.command("stop", async (ctx) => {
96
+ const session = getScopedSession(ctx, store, projects, config);
97
+ if (!session)
98
+ return;
99
+ const latest = store.get(session.sessionKey) ?? session;
100
+ if (!codex.isRunning(session.sessionKey)) {
101
+ await ctx.reply("There is no active Codex SDK turn right now.");
102
+ return;
103
+ }
104
+ try {
105
+ codex.interrupt(session.sessionKey);
106
+ await ctx.reply("Interrupt requested for the current run. Waiting for Codex SDK to stop.");
107
+ }
108
+ catch (error) {
109
+ logger?.warn("interrupt turn failed", {
110
+ ...contextLogFields(ctx),
111
+ ...sessionLogFields(latest),
112
+ error,
113
+ });
114
+ await ctx.reply(`Interrupt failed: ${error instanceof Error ? error.message : String(error)}`);
115
+ }
116
+ });
117
+ }
118
+ function formatQueuedPreview(items) {
119
+ if (items.length === 0)
120
+ return "none";
121
+ return items.map((item) => singleLinePreview(item.text)).join(" | ");
122
+ }
123
+ function formatQueuedItems(items) {
124
+ return items.map((item) => `#${item.id} ${singleLinePreview(item.text)} (${formatIsoTimestamp(item.createdAt)})`).join("\n");
125
+ }
126
+ function singleLinePreview(text, maxLength = 48) {
127
+ const normalized = text.replace(/\s+/g, " ").trim();
128
+ if (normalized.length <= maxLength)
129
+ return normalized;
130
+ return `${normalized.slice(0, maxLength - 3)}...`;
131
+ }
@@ -0,0 +1,192 @@
1
+ import { contextLogFields, ensureTopicSession, formatPrivateProjectList, formatProjectStatus, formatTopicName, getProjectForContext, getScopedSession, hasTopicContext, isPrivateChat, isSupergroupChat, parseSubcommand, postTopicReadyMessage, resolveExistingDirectory, } from "../commandSupport.js";
2
+ import { formatSessionRuntimeStatus } from "../../runtime/sessionRuntime.js";
3
+ const PROJECT_REQUIRED_MESSAGE = "This supergroup has no project bound yet.\nRun /project bind <absolute-path> first.";
4
+ export function registerProjectHandlers(deps) {
5
+ const { bot, config, store, projects, logger } = deps;
6
+ bot.command("project", async (ctx) => {
7
+ const { command, args } = parseSubcommand(ctx.match.trim());
8
+ if (isPrivateChat(ctx)) {
9
+ if (!command || command === "list" || command === "status") {
10
+ await ctx.reply(formatPrivateProjectList(projects));
11
+ return;
12
+ }
13
+ await ctx.reply("Use /project bind inside a supergroup with topics enabled. Private chat is only for admin entry points.");
14
+ return;
15
+ }
16
+ if (!isSupergroupChat(ctx)) {
17
+ await ctx.reply("Use telecodex inside a supergroup with forum topics enabled.");
18
+ return;
19
+ }
20
+ if (!command || command === "status") {
21
+ const project = getProjectForContext(ctx, projects);
22
+ await ctx.reply(project ? formatProjectStatus(project) : PROJECT_REQUIRED_MESSAGE);
23
+ return;
24
+ }
25
+ if (command === "bind") {
26
+ if (!args) {
27
+ await ctx.reply("Usage: /project bind <absolute-path>");
28
+ return;
29
+ }
30
+ try {
31
+ const cwd = resolveExistingDirectory(args);
32
+ const project = projects.upsert({
33
+ chatId: String(ctx.chat.id),
34
+ cwd,
35
+ });
36
+ logger?.info("project bound", {
37
+ ...contextLogFields(ctx),
38
+ project: project.name,
39
+ cwd: project.cwd,
40
+ });
41
+ const session = getScopedSession(ctx, store, projects, config, { requireTopic: false });
42
+ if (session && session.cwd !== project.cwd) {
43
+ store.setCwd(session.sessionKey, project.cwd);
44
+ }
45
+ await ctx.reply([
46
+ "Project binding updated.",
47
+ `project: ${project.name}`,
48
+ `root: ${project.cwd}`,
49
+ "This supergroup now represents one project, and each topic maps to one Codex thread.",
50
+ ].join("\n"));
51
+ }
52
+ catch (error) {
53
+ await ctx.reply(error instanceof Error ? error.message : String(error));
54
+ }
55
+ return;
56
+ }
57
+ if (command === "unbind") {
58
+ logger?.info("project unbound", {
59
+ ...contextLogFields(ctx),
60
+ });
61
+ projects.remove(String(ctx.chat.id));
62
+ await ctx.reply("Removed the project binding for this supergroup.");
63
+ return;
64
+ }
65
+ await ctx.reply("Usage:\n/project\n/project bind <absolute-path>\n/project unbind");
66
+ });
67
+ bot.command("thread", async (ctx) => {
68
+ if (isPrivateChat(ctx)) {
69
+ await ctx.reply("The thread command is only available inside project supergroups.");
70
+ return;
71
+ }
72
+ const { command, args } = parseSubcommand(ctx.match.trim());
73
+ if (!command) {
74
+ if (hasTopicContext(ctx)) {
75
+ const session = getScopedSession(ctx, store, projects, config);
76
+ if (!session)
77
+ return;
78
+ await ctx.reply([
79
+ `Current thread: ${session.codexThreadId ?? "not created"}`,
80
+ `state: ${formatSessionRuntimeStatus(session.runtimeStatus)}`,
81
+ `state detail: ${session.runtimeStatusDetail ?? "none"}`,
82
+ `queue: ${store.getQueuedInputCount(session.sessionKey)}`,
83
+ `cwd: ${session.cwd}`,
84
+ "Manage threads in this project:",
85
+ "/thread resume <threadId>",
86
+ "/thread new <topic-name>",
87
+ ].join("\n"));
88
+ return;
89
+ }
90
+ await ctx.reply("Usage:\n/thread resume <threadId>\n/thread new <topic-name>");
91
+ return;
92
+ }
93
+ if (command === "resume") {
94
+ if (!args) {
95
+ await ctx.reply("Usage: /thread resume <threadId>");
96
+ return;
97
+ }
98
+ await resumeThreadIntoTopic(ctx, deps, args);
99
+ return;
100
+ }
101
+ if (command === "new") {
102
+ await createFreshThreadTopic(ctx, deps, args);
103
+ return;
104
+ }
105
+ await ctx.reply("Usage:\n/thread resume <threadId>\n/thread new <topic-name>");
106
+ });
107
+ bot.on(["message:forum_topic_created", "message:forum_topic_edited"], async (ctx) => {
108
+ const threadId = ctx.message.message_thread_id;
109
+ if (threadId == null)
110
+ return;
111
+ const topicName = ctx.message.forum_topic_created?.name ?? ctx.message.forum_topic_edited?.name ?? null;
112
+ if (!topicName)
113
+ return;
114
+ const sessionKey = `${ctx.chat.id}:${threadId}`;
115
+ const session = store.get(sessionKey);
116
+ if (!session)
117
+ return;
118
+ store.setTelegramTopicName(sessionKey, topicName);
119
+ });
120
+ }
121
+ async function resumeThreadIntoTopic(ctx, deps, threadId) {
122
+ const { bot, config, store, projects, logger } = deps;
123
+ const project = getProjectForContext(ctx, projects);
124
+ if (!project) {
125
+ await ctx.reply(PROJECT_REQUIRED_MESSAGE);
126
+ return;
127
+ }
128
+ const topicName = formatTopicName(`Resumed ${threadId.slice(0, 8)}`, "Resumed Thread");
129
+ const forumTopic = await bot.api.createForumTopic(ctx.chat.id, topicName);
130
+ const session = ensureTopicSession({
131
+ store,
132
+ config,
133
+ project,
134
+ chatId: ctx.chat.id,
135
+ messageThreadId: forumTopic.message_thread_id,
136
+ topicName: forumTopic.name,
137
+ threadId,
138
+ });
139
+ logger?.info("thread id bound into topic", {
140
+ ...contextLogFields(ctx),
141
+ sessionKey: session.sessionKey,
142
+ threadId,
143
+ topicName: forumTopic.name,
144
+ });
145
+ await ctx.reply([
146
+ "Created a topic and bound it to the existing thread id.",
147
+ `topic: ${forumTopic.name}`,
148
+ `topic id: ${forumTopic.message_thread_id}`,
149
+ `thread: ${threadId}`,
150
+ "Future messages in this topic will continue on that thread through the Codex SDK.",
151
+ ].join("\n"));
152
+ await postTopicReadyMessage(bot, session, [
153
+ "This topic is now bound to an existing Codex thread id.",
154
+ `thread: ${threadId}`,
155
+ "Send a message to continue.",
156
+ ].join("\n"));
157
+ }
158
+ async function createFreshThreadTopic(ctx, deps, requestedName) {
159
+ const { bot, config, store, projects, logger } = deps;
160
+ const project = getProjectForContext(ctx, projects);
161
+ if (!project) {
162
+ await ctx.reply(PROJECT_REQUIRED_MESSAGE);
163
+ return;
164
+ }
165
+ const topicName = formatTopicName(requestedName, "New Thread");
166
+ const forumTopic = await bot.api.createForumTopic(ctx.chat.id, topicName);
167
+ const session = ensureTopicSession({
168
+ store,
169
+ config,
170
+ project,
171
+ chatId: ctx.chat.id,
172
+ messageThreadId: forumTopic.message_thread_id,
173
+ topicName: forumTopic.name,
174
+ });
175
+ logger?.info("new thread topic created", {
176
+ ...contextLogFields(ctx),
177
+ sessionKey: session.sessionKey,
178
+ topicName: forumTopic.name,
179
+ });
180
+ await ctx.reply([
181
+ "Created a new topic.",
182
+ `topic: ${forumTopic.name}`,
183
+ `topic id: ${forumTopic.message_thread_id}`,
184
+ "Your first normal message will start a new Codex SDK thread.",
185
+ ].join("\n"));
186
+ await postTopicReadyMessage(bot, session, [
187
+ "New topic created.",
188
+ "Send a message to start a new Codex thread.",
189
+ `cwd: ${session.cwd}`,
190
+ `model: ${session.model}`,
191
+ ].join("\n"));
192
+ }
@@ -0,0 +1,319 @@
1
+ import { APPROVAL_POLICIES, MODE_PRESETS, REASONING_EFFORTS, SANDBOX_MODES, WEB_SEARCH_MODES, isSessionApprovalPolicy, isSessionModePreset, isSessionReasoningEffort, isSessionSandboxMode, isSessionWebSearchMode, presetFromProfile, profileFromPreset, } from "../../config.js";
2
+ import { assertProjectScopedPath, formatProfileReply, formatReasoningEffort, getProjectForContext, getScopedSession, resolveExistingDirectory, } from "../commandSupport.js";
3
+ export function registerSessionConfigHandlers(deps) {
4
+ const { bot, config, store, projects, codex } = deps;
5
+ bot.command("cwd", async (ctx) => {
6
+ const project = getProjectForContext(ctx, projects);
7
+ const session = getScopedSession(ctx, store, projects, config);
8
+ if (!project || !session)
9
+ return;
10
+ const cwd = ctx.match.trim();
11
+ if (!cwd) {
12
+ await ctx.reply(`Current directory: ${session.cwd}\nProject root: ${project.cwd}`);
13
+ return;
14
+ }
15
+ try {
16
+ const allowed = assertProjectScopedPath(cwd, project.cwd);
17
+ store.setCwd(session.sessionKey, allowed);
18
+ await ctx.reply(`Set cwd:\n${allowed}`);
19
+ }
20
+ catch (error) {
21
+ await ctx.reply(error instanceof Error ? error.message : String(error));
22
+ }
23
+ });
24
+ bot.command("mode", async (ctx) => {
25
+ const session = getScopedSession(ctx, store, projects, config);
26
+ if (!session)
27
+ return;
28
+ const preset = ctx.match.trim();
29
+ if (!preset) {
30
+ await ctx.reply([
31
+ `Current preset: ${presetFromProfile(session)}`,
32
+ `sandbox: ${session.sandboxMode}`,
33
+ `approval: ${session.approvalPolicy}`,
34
+ `Usage: /mode ${MODE_PRESETS.join("|")}`,
35
+ ].join("\n"));
36
+ return;
37
+ }
38
+ if (!isSessionModePreset(preset)) {
39
+ await ctx.reply(`Invalid preset.\nUsage: /mode ${MODE_PRESETS.join("|")}`);
40
+ return;
41
+ }
42
+ const profile = profileFromPreset(preset);
43
+ store.setSandboxMode(session.sessionKey, profile.sandboxMode);
44
+ store.setApprovalPolicy(session.sessionKey, profile.approvalPolicy);
45
+ await ctx.reply(formatProfileReply("Preset updated.", profile.sandboxMode, profile.approvalPolicy));
46
+ });
47
+ bot.command("sandbox", async (ctx) => {
48
+ const session = getScopedSession(ctx, store, projects, config);
49
+ if (!session)
50
+ return;
51
+ const sandboxMode = ctx.match.trim();
52
+ if (!sandboxMode) {
53
+ await ctx.reply(`Current sandbox: ${session.sandboxMode}\nUsage: /sandbox ${SANDBOX_MODES.join("|")}`);
54
+ return;
55
+ }
56
+ if (!isSessionSandboxMode(sandboxMode)) {
57
+ await ctx.reply(`Invalid sandbox.\nUsage: /sandbox ${SANDBOX_MODES.join("|")}`);
58
+ return;
59
+ }
60
+ store.setSandboxMode(session.sessionKey, sandboxMode);
61
+ await ctx.reply(formatProfileReply("Sandbox updated.", sandboxMode, session.approvalPolicy));
62
+ });
63
+ bot.command("approval", async (ctx) => {
64
+ const session = getScopedSession(ctx, store, projects, config);
65
+ if (!session)
66
+ return;
67
+ const approvalPolicy = ctx.match.trim();
68
+ if (!approvalPolicy) {
69
+ await ctx.reply(`Current approval: ${session.approvalPolicy}\nUsage: /approval ${APPROVAL_POLICIES.join("|")}`);
70
+ return;
71
+ }
72
+ if (!isSessionApprovalPolicy(approvalPolicy)) {
73
+ await ctx.reply(`Invalid approval policy.\nUsage: /approval ${APPROVAL_POLICIES.join("|")}`);
74
+ return;
75
+ }
76
+ store.setApprovalPolicy(session.sessionKey, approvalPolicy);
77
+ await ctx.reply(formatProfileReply("Approval policy updated.", session.sandboxMode, approvalPolicy));
78
+ });
79
+ bot.command("yolo", async (ctx) => {
80
+ const session = getScopedSession(ctx, store, projects, config);
81
+ if (!session)
82
+ return;
83
+ const value = ctx.match.trim().toLowerCase();
84
+ if (!value) {
85
+ const enabled = session.sandboxMode === "danger-full-access" && session.approvalPolicy === "never";
86
+ await ctx.reply(`Current yolo: ${enabled ? "on" : "off"}\nUsage: /yolo on|off`);
87
+ return;
88
+ }
89
+ if (value !== "on" && value !== "off") {
90
+ await ctx.reply("Usage: /yolo on|off");
91
+ return;
92
+ }
93
+ const profile = profileFromPreset(value === "on" ? "yolo" : "write");
94
+ store.setSandboxMode(session.sessionKey, profile.sandboxMode);
95
+ store.setApprovalPolicy(session.sessionKey, profile.approvalPolicy);
96
+ await ctx.reply(formatProfileReply(value === "on" ? "YOLO enabled." : "YOLO disabled. Restored the write preset.", profile.sandboxMode, profile.approvalPolicy));
97
+ });
98
+ bot.command("model", async (ctx) => {
99
+ const session = getScopedSession(ctx, store, projects, config);
100
+ if (!session)
101
+ return;
102
+ const model = ctx.match.trim();
103
+ if (!model) {
104
+ await ctx.reply(`Current model: ${session.model}`);
105
+ return;
106
+ }
107
+ store.setModel(session.sessionKey, model);
108
+ await ctx.reply(`Set model: ${model}`);
109
+ });
110
+ bot.command("effort", async (ctx) => {
111
+ const session = getScopedSession(ctx, store, projects, config);
112
+ if (!session)
113
+ return;
114
+ const value = ctx.match.trim().toLowerCase();
115
+ if (!value) {
116
+ await ctx.reply(`Current reasoning effort: ${formatReasoningEffort(session.reasoningEffort)}\nUsage: /effort default|${REASONING_EFFORTS.join("|")}`);
117
+ return;
118
+ }
119
+ if (value !== "default" && !isSessionReasoningEffort(value)) {
120
+ await ctx.reply(`Invalid reasoning effort.\nUsage: /effort default|${REASONING_EFFORTS.join("|")}`);
121
+ return;
122
+ }
123
+ if (value === "default") {
124
+ store.setReasoningEffort(session.sessionKey, null);
125
+ }
126
+ else {
127
+ store.setReasoningEffort(session.sessionKey, value);
128
+ }
129
+ await ctx.reply(`Set reasoning effort: ${value === "default" ? "codex-default" : value}`);
130
+ });
131
+ bot.command("web", async (ctx) => {
132
+ const session = getScopedSession(ctx, store, projects, config);
133
+ if (!session)
134
+ return;
135
+ const value = ctx.match.trim().toLowerCase();
136
+ if (!value) {
137
+ await ctx.reply(`Current web search: ${session.webSearchMode ?? "codex-default"}\nUsage: /web default|${WEB_SEARCH_MODES.join("|")}`);
138
+ return;
139
+ }
140
+ if (value !== "default" && !isSessionWebSearchMode(value)) {
141
+ await ctx.reply(`Invalid web search mode.\nUsage: /web default|${WEB_SEARCH_MODES.join("|")}`);
142
+ return;
143
+ }
144
+ store.setWebSearchMode(session.sessionKey, value === "default" ? null : value);
145
+ await ctx.reply(`Set web search: ${value === "default" ? "codex-default" : value}`);
146
+ });
147
+ bot.command("network", async (ctx) => {
148
+ const session = getScopedSession(ctx, store, projects, config);
149
+ if (!session)
150
+ return;
151
+ const value = ctx.match.trim().toLowerCase();
152
+ if (!value) {
153
+ await ctx.reply(`Current network access: ${session.networkAccessEnabled ? "on" : "off"}\nUsage: /network on|off`);
154
+ return;
155
+ }
156
+ if (value !== "on" && value !== "off") {
157
+ await ctx.reply("Usage: /network on|off");
158
+ return;
159
+ }
160
+ store.setNetworkAccessEnabled(session.sessionKey, value === "on");
161
+ await ctx.reply(`Set network access: ${value}`);
162
+ });
163
+ bot.command("gitcheck", async (ctx) => {
164
+ const session = getScopedSession(ctx, store, projects, config);
165
+ if (!session)
166
+ return;
167
+ const value = ctx.match.trim().toLowerCase();
168
+ if (!value) {
169
+ await ctx.reply(`Current git repo check: ${session.skipGitRepoCheck ? "skip" : "enforce"}\nUsage: /gitcheck skip|enforce`);
170
+ return;
171
+ }
172
+ if (value !== "skip" && value !== "enforce") {
173
+ await ctx.reply("Usage: /gitcheck skip|enforce");
174
+ return;
175
+ }
176
+ store.setSkipGitRepoCheck(session.sessionKey, value === "skip");
177
+ await ctx.reply(`Set git repo check: ${value}`);
178
+ });
179
+ bot.command("adddir", async (ctx) => {
180
+ const session = getScopedSession(ctx, store, projects, config);
181
+ if (!session)
182
+ return;
183
+ const [command, ...rest] = ctx.match.trim().split(/\s+/).filter(Boolean);
184
+ const args = rest.join(" ");
185
+ if (!command || command === "list") {
186
+ await ctx.reply(session.additionalDirectories.length === 0
187
+ ? "additional directories: none\nUsage: /adddir add <absolute-path> | /adddir drop <index> | /adddir clear"
188
+ : [
189
+ "additional directories:",
190
+ ...session.additionalDirectories.map((directory, index) => `${index + 1}. ${directory}`),
191
+ "Usage: /adddir add <absolute-path> | /adddir drop <index> | /adddir clear",
192
+ ].join("\n"));
193
+ return;
194
+ }
195
+ if (command === "add") {
196
+ if (!args) {
197
+ await ctx.reply("Usage: /adddir add <absolute-path>");
198
+ return;
199
+ }
200
+ try {
201
+ const directory = resolveExistingDirectory(args);
202
+ const next = [...session.additionalDirectories.filter((entry) => entry !== directory), directory];
203
+ store.setAdditionalDirectories(session.sessionKey, next);
204
+ await ctx.reply(`Added additional directory:\n${directory}`);
205
+ }
206
+ catch (error) {
207
+ await ctx.reply(error instanceof Error ? error.message : String(error));
208
+ }
209
+ return;
210
+ }
211
+ if (command === "drop") {
212
+ const index = Number(args);
213
+ if (!Number.isInteger(index) || index <= 0 || index > session.additionalDirectories.length) {
214
+ await ctx.reply("Usage: /adddir drop <index>");
215
+ return;
216
+ }
217
+ const next = session.additionalDirectories.filter((_entry, entryIndex) => entryIndex !== index - 1);
218
+ store.setAdditionalDirectories(session.sessionKey, next);
219
+ await ctx.reply(`Removed additional directory #${index}.`);
220
+ return;
221
+ }
222
+ if (command === "clear") {
223
+ store.setAdditionalDirectories(session.sessionKey, []);
224
+ await ctx.reply("Cleared additional directories.");
225
+ return;
226
+ }
227
+ await ctx.reply("Usage: /adddir list | /adddir add <absolute-path> | /adddir drop <index> | /adddir clear");
228
+ });
229
+ bot.command("schema", async (ctx) => {
230
+ const session = getScopedSession(ctx, store, projects, config);
231
+ if (!session)
232
+ return;
233
+ const raw = ctx.match.trim();
234
+ if (!raw || raw === "show") {
235
+ await ctx.reply(session.outputSchema ? `Current output schema:\n${session.outputSchema}` : "Current output schema: none\nUsage: /schema set <JSON object> | /schema clear");
236
+ return;
237
+ }
238
+ if (raw === "clear") {
239
+ store.setOutputSchema(session.sessionKey, null);
240
+ await ctx.reply("Cleared output schema.");
241
+ return;
242
+ }
243
+ if (!raw.startsWith("set ")) {
244
+ await ctx.reply("Usage: /schema show | /schema set <JSON object> | /schema clear");
245
+ return;
246
+ }
247
+ try {
248
+ const parsed = JSON.parse(raw.slice(4).trim());
249
+ if (!isPlainObject(parsed)) {
250
+ await ctx.reply("Output schema must be a JSON object.");
251
+ return;
252
+ }
253
+ const normalized = JSON.stringify(parsed);
254
+ store.setOutputSchema(session.sessionKey, normalized);
255
+ await ctx.reply(`Set output schema:\n${normalized}`);
256
+ }
257
+ catch (error) {
258
+ await ctx.reply(`Failed to parse JSON: ${error instanceof Error ? error.message : String(error)}`);
259
+ }
260
+ });
261
+ bot.command("codexconfig", async (ctx) => {
262
+ const raw = ctx.match.trim();
263
+ if (!raw || raw === "show") {
264
+ const current = store.getAppState("codex_config_overrides");
265
+ await ctx.reply(current ? `Current Codex config overrides:\n${current}` : "Current Codex config overrides: none\nUsage: /codexconfig set <JSON object> | /codexconfig clear");
266
+ return;
267
+ }
268
+ if (raw === "clear") {
269
+ store.deleteAppState("codex_config_overrides");
270
+ codex.setConfigOverrides(undefined);
271
+ await ctx.reply("Cleared Codex config overrides. They will apply to future runs.");
272
+ return;
273
+ }
274
+ if (!raw.startsWith("set ")) {
275
+ await ctx.reply("Usage: /codexconfig show | /codexconfig set <JSON object> | /codexconfig clear");
276
+ return;
277
+ }
278
+ try {
279
+ const configOverrides = parseCodexConfigOverrides(raw.slice(4).trim());
280
+ const serialized = JSON.stringify(configOverrides);
281
+ store.setAppState("codex_config_overrides", serialized);
282
+ codex.setConfigOverrides(configOverrides);
283
+ await ctx.reply(`Set Codex config overrides. They will apply to future runs.\n${serialized}`);
284
+ }
285
+ catch (error) {
286
+ await ctx.reply(error instanceof Error ? error.message : String(error));
287
+ }
288
+ });
289
+ }
290
+ function isPlainObject(value) {
291
+ return typeof value === "object" && value !== null && !Array.isArray(value);
292
+ }
293
+ function parseCodexConfigOverrides(raw) {
294
+ const parsed = JSON.parse(raw);
295
+ if (!isPlainObject(parsed)) {
296
+ throw new Error("Codex config overrides must be a JSON object.");
297
+ }
298
+ assertCodexConfigValue(parsed, "config");
299
+ return parsed;
300
+ }
301
+ function assertCodexConfigValue(value, path) {
302
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
303
+ return;
304
+ if (Array.isArray(value)) {
305
+ for (let index = 0; index < value.length; index += 1) {
306
+ assertCodexConfigValue(value[index], `${path}[${index}]`);
307
+ }
308
+ return;
309
+ }
310
+ if (isPlainObject(value)) {
311
+ for (const [key, child] of Object.entries(value)) {
312
+ if (!key)
313
+ throw new Error("Codex config override key cannot be empty.");
314
+ assertCodexConfigValue(child, `${path}.${key}`);
315
+ }
316
+ return;
317
+ }
318
+ throw new Error(`${path} may only contain string, number, boolean, array, or object values.`);
319
+ }