lynkr 8.0.1 → 9.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * Codex App-Server Process Manager
3
+ *
4
+ * Manages a persistent `codex app-server` child process that communicates
5
+ * via JSON-RPC over stdio. Inherits the user's local ChatGPT subscription
6
+ * auth, so no API key is needed.
7
+ *
8
+ * @module clients/codex-process
9
+ */
10
+
11
+ const { spawn, execSync } = require("node:child_process");
12
+ const readline = require("node:readline");
13
+ const logger = require("../logger");
14
+ const config = require("../config");
15
+
16
+ const DEFAULT_TIMEOUT_MS = 120_000;
17
+
18
+ class CodexProcess {
19
+ constructor() {
20
+ this.child = null;
21
+ this.pendingRequests = new Map(); // id -> { resolve, reject, timeout }
22
+ this.nextId = 1;
23
+ this.initialized = false;
24
+ this.accountInfo = null;
25
+ this.buffer = "";
26
+ this.restartCount = 0;
27
+ this._turnCollector = null; // active turn content collector
28
+ }
29
+
30
+ /**
31
+ * Check if the codex binary is available on PATH
32
+ */
33
+ static isAvailable() {
34
+ try {
35
+ const binaryPath = config.codex?.binaryPath || "codex";
36
+ execSync(`which ${binaryPath}`, { stdio: "ignore" });
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Ensure the codex app-server process is running and initialized
45
+ */
46
+ async ensureRunning() {
47
+ if (this.child && this.initialized) return;
48
+
49
+ if (this.child) {
50
+ // Process exists but not initialized — wait or restart
51
+ this._killProcess();
52
+ }
53
+
54
+ const binaryPath = config.codex?.binaryPath || "codex";
55
+
56
+ logger.info({ binaryPath, restart: this.restartCount }, "Spawning codex app-server");
57
+
58
+ this.child = spawn(binaryPath, ["app-server"], {
59
+ cwd: process.cwd(),
60
+ env: { ...process.env },
61
+ stdio: ["pipe", "pipe", "pipe"],
62
+ shell: process.platform === "win32",
63
+ });
64
+
65
+ const rl = readline.createInterface({ input: this.child.stdout });
66
+ rl.on("line", (line) => this._onLine(line));
67
+
68
+ this.child.stderr.on("data", (data) => {
69
+ const msg = data.toString().trim();
70
+ if (msg) {
71
+ logger.debug({ stderr: msg }, "[Codex] stderr");
72
+ }
73
+ });
74
+
75
+ this.child.on("exit", (code, signal) => {
76
+ logger.warn({ code, signal, restartCount: this.restartCount }, "[Codex] Process exited");
77
+ this._rejectAllPending(new Error(`Codex process exited with code ${code}`));
78
+ this.child = null;
79
+ this.initialized = false;
80
+ this.restartCount++;
81
+ });
82
+
83
+ this.child.on("error", (err) => {
84
+ logger.error({ err: err.message }, "[Codex] Process error");
85
+ this._rejectAllPending(err);
86
+ this.child = null;
87
+ this.initialized = false;
88
+ });
89
+
90
+ // Handshake
91
+ await this.sendRequest("initialize", {
92
+ protocolVersion: "2025-01-01",
93
+ capabilities: {},
94
+ clientInfo: { name: "lynkr", version: "1.0.0" },
95
+ });
96
+
97
+ this._sendNotification("initialized", {});
98
+
99
+ // Read account info
100
+ try {
101
+ const accountResp = await this.sendRequest("account/read", {});
102
+ this.accountInfo = this._parseAccount(accountResp);
103
+ logger.info({
104
+ type: this.accountInfo.type,
105
+ planType: this.accountInfo.planType,
106
+ }, "[Codex] Account info");
107
+ } catch (err) {
108
+ logger.warn({ err: err.message }, "[Codex] account/read failed");
109
+ this.accountInfo = { type: "unknown", planType: null };
110
+ }
111
+
112
+ this.initialized = true;
113
+ logger.info("[Codex] App-server initialized");
114
+ }
115
+
116
+ /**
117
+ * Send a JSON-RPC request and wait for response
118
+ */
119
+ sendRequest(method, params, timeoutMs) {
120
+ const timeout = timeoutMs || config.codex?.timeout || DEFAULT_TIMEOUT_MS;
121
+
122
+ return new Promise((resolve, reject) => {
123
+ if (!this.child) {
124
+ reject(new Error("Codex process not running"));
125
+ return;
126
+ }
127
+
128
+ const id = this.nextId++;
129
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
130
+
131
+ const timer = setTimeout(() => {
132
+ this.pendingRequests.delete(id);
133
+ reject(new Error(`Codex request "${method}" timed out after ${timeout}ms`));
134
+ }, timeout);
135
+
136
+ this.pendingRequests.set(id, { resolve, reject, timeout: timer, method });
137
+
138
+ this.child.stdin.write(msg + "\n");
139
+ logger.debug({ id, method }, "[Codex] Sent request");
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Send a turn and collect all streaming content events
145
+ * Returns the accumulated response text
146
+ */
147
+ async sendTurn(threadId, content, model) {
148
+ return new Promise((resolve, reject) => {
149
+ const collectedContent = [];
150
+ let turnId = null;
151
+
152
+ // Set up collector for streaming notifications
153
+ this._turnCollector = {
154
+ threadId,
155
+ content: collectedContent,
156
+ onComplete: (result) => {
157
+ this._turnCollector = null;
158
+ resolve({
159
+ text: collectedContent.join(""),
160
+ turnId,
161
+ raw: result,
162
+ });
163
+ },
164
+ onError: (err) => {
165
+ this._turnCollector = null;
166
+ reject(err);
167
+ },
168
+ };
169
+
170
+ // Send the turn/start request
171
+ const id = this.nextId++;
172
+ const params = { threadId, content };
173
+ if (model) params.model = model;
174
+
175
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method: "turn/start", params });
176
+
177
+ const timeout = config.codex?.timeout || DEFAULT_TIMEOUT_MS;
178
+ const timer = setTimeout(() => {
179
+ this.pendingRequests.delete(id);
180
+ this._turnCollector = null;
181
+ reject(new Error(`Codex turn timed out after ${timeout}ms`));
182
+ }, timeout);
183
+
184
+ this.pendingRequests.set(id, {
185
+ resolve: (result) => {
186
+ turnId = result?.turnId || null;
187
+ // The collector's onComplete will be called by turn/completed notification
188
+ // If no streaming, resolve immediately
189
+ if (!this._turnCollector) {
190
+ resolve({ text: collectedContent.join(""), turnId, raw: result });
191
+ }
192
+ },
193
+ reject: (err) => {
194
+ this._turnCollector = null;
195
+ reject(err);
196
+ },
197
+ timeout: timer,
198
+ method: "turn/start",
199
+ });
200
+
201
+ this.child.stdin.write(msg + "\n");
202
+ logger.debug({ id, threadId }, "[Codex] Sent turn/start");
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Handle a line from codex stdout
208
+ */
209
+ _onLine(line) {
210
+ if (!line.trim()) return;
211
+
212
+ let parsed;
213
+ try {
214
+ parsed = JSON.parse(line);
215
+ } catch {
216
+ logger.debug({ line: line.substring(0, 200) }, "[Codex] Non-JSON stdout");
217
+ return;
218
+ }
219
+
220
+ // JSON-RPC response (has id)
221
+ if (parsed.id !== undefined) {
222
+ const pending = this.pendingRequests.get(parsed.id);
223
+ if (pending) {
224
+ clearTimeout(pending.timeout);
225
+ this.pendingRequests.delete(parsed.id);
226
+
227
+ if (parsed.error) {
228
+ pending.reject(new Error(parsed.error.message || JSON.stringify(parsed.error)));
229
+ } else {
230
+ pending.resolve(parsed.result);
231
+ }
232
+ }
233
+ return;
234
+ }
235
+
236
+ // JSON-RPC notification (no id) — streaming events
237
+ const method = parsed.method;
238
+ const params = parsed.params || {};
239
+
240
+ if (method === "item/message/outputText/delta" && this._turnCollector) {
241
+ const delta = params.delta || params.text || "";
242
+ if (delta) {
243
+ this._turnCollector.content.push(delta);
244
+ }
245
+ } else if (method === "item/message/outputText/done" && this._turnCollector) {
246
+ const text = params.text || "";
247
+ if (text && this._turnCollector.content.length === 0) {
248
+ this._turnCollector.content.push(text);
249
+ }
250
+ } else if (method === "turn/completed" && this._turnCollector) {
251
+ this._turnCollector.onComplete(params);
252
+ } else if (method === "turn/error" && this._turnCollector) {
253
+ this._turnCollector.onError(new Error(params.message || "Codex turn error"));
254
+ } else {
255
+ logger.debug({ method, params: JSON.stringify(params).substring(0, 200) }, "[Codex] Notification");
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Send a JSON-RPC notification (no response expected)
261
+ */
262
+ _sendNotification(method, params) {
263
+ if (!this.child) return;
264
+ const msg = JSON.stringify({ jsonrpc: "2.0", method, params });
265
+ this.child.stdin.write(msg + "\n");
266
+ }
267
+
268
+ /**
269
+ * Parse account/read response
270
+ */
271
+ _parseAccount(response) {
272
+ const account = response?.account || response || {};
273
+ const type = account.type || "unknown";
274
+ const planType = account.planType || null;
275
+ return { type, planType };
276
+ }
277
+
278
+ /**
279
+ * Reject all pending requests
280
+ */
281
+ _rejectAllPending(error) {
282
+ for (const [id, pending] of this.pendingRequests) {
283
+ clearTimeout(pending.timeout);
284
+ pending.reject(error);
285
+ }
286
+ this.pendingRequests.clear();
287
+
288
+ if (this._turnCollector) {
289
+ this._turnCollector.onError(error);
290
+ this._turnCollector = null;
291
+ }
292
+ }
293
+
294
+ /**
295
+ * Kill the child process
296
+ */
297
+ _killProcess() {
298
+ if (!this.child) return;
299
+ try {
300
+ if (process.platform === "win32") {
301
+ execSync(`taskkill /pid ${this.child.pid} /T /F`, { stdio: "ignore" });
302
+ } else {
303
+ this.child.kill("SIGTERM");
304
+ }
305
+ } catch {
306
+ try { this.child.kill("SIGKILL"); } catch { /* ignore */ }
307
+ }
308
+ this.child = null;
309
+ this.initialized = false;
310
+ }
311
+
312
+ /**
313
+ * Graceful shutdown
314
+ */
315
+ async shutdown() {
316
+ logger.info("[Codex] Shutting down app-server");
317
+ this._rejectAllPending(new Error("Codex shutting down"));
318
+ this._killProcess();
319
+ }
320
+
321
+ /**
322
+ * Get cached account info
323
+ */
324
+ getAccountInfo() {
325
+ return this.accountInfo;
326
+ }
327
+ }
328
+
329
+ // Singleton instance
330
+ let instance = null;
331
+
332
+ function getCodexProcess() {
333
+ if (!instance) {
334
+ instance = new CodexProcess();
335
+ }
336
+ return instance;
337
+ }
338
+
339
+ module.exports = {
340
+ CodexProcess,
341
+ getCodexProcess,
342
+ };
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Codex Format Conversion Utilities
3
+ *
4
+ * Converts between Anthropic/Lynkr internal message format
5
+ * and Codex app-server JSON-RPC format.
6
+ *
7
+ * @module clients/codex-utils
8
+ */
9
+
10
+ const logger = require("../logger");
11
+
12
+ /**
13
+ * Extract text content from Anthropic message format
14
+ * Handles both string content and content block arrays
15
+ */
16
+ function extractText(message) {
17
+ if (!message) return "";
18
+ const content = message.content;
19
+
20
+ if (typeof content === "string") return content;
21
+ if (!Array.isArray(content)) return "";
22
+
23
+ return content
24
+ .map((block) => {
25
+ if (block.type === "text") return block.text || "";
26
+ if (block.type === "tool_result") {
27
+ const result = typeof block.content === "string"
28
+ ? block.content
29
+ : JSON.stringify(block.content);
30
+ return `[Tool Result: ${result}]`;
31
+ }
32
+ if (block.type === "tool_use") {
33
+ return `[Tool Call: ${block.name}(${JSON.stringify(block.input)})]`;
34
+ }
35
+ return "";
36
+ })
37
+ .filter(Boolean)
38
+ .join("\n");
39
+ }
40
+
41
+ /**
42
+ * Convert Anthropic request body to a Codex turn prompt
43
+ *
44
+ * Strategy: Codex turn/start takes a simple text prompt.
45
+ * We flatten system + message history into a single prompt.
46
+ *
47
+ * @param {Object} body - Lynkr internal body (Anthropic format)
48
+ * @returns {{ prompt: string, systemContext: string|null }}
49
+ */
50
+ function convertAnthropicToCodexPrompt(body) {
51
+ const systemContext = body.system || null;
52
+ const messages = body.messages || [];
53
+
54
+ if (messages.length === 0) {
55
+ return { prompt: "", systemContext };
56
+ }
57
+
58
+ // If only one user message, use it directly
59
+ if (messages.length === 1 && messages[0].role === "user") {
60
+ return {
61
+ prompt: extractText(messages[0]),
62
+ systemContext,
63
+ };
64
+ }
65
+
66
+ // Multiple messages — flatten into a conversation prompt
67
+ // Keep last user message as the main prompt
68
+ // Include prior messages as context
69
+ const lastUserIndex = findLastIndex(messages, (m) => m.role === "user");
70
+ if (lastUserIndex === -1) {
71
+ return { prompt: extractText(messages[messages.length - 1]), systemContext };
72
+ }
73
+
74
+ const lastUserMessage = extractText(messages[lastUserIndex]);
75
+
76
+ // Build context from prior messages
77
+ const priorMessages = messages.slice(0, lastUserIndex);
78
+ if (priorMessages.length === 0) {
79
+ return { prompt: lastUserMessage, systemContext };
80
+ }
81
+
82
+ const contextParts = priorMessages.map((m) => {
83
+ const text = extractText(m);
84
+ if (!text) return null;
85
+ const role = m.role === "user" ? "User" : "Assistant";
86
+ return `${role}: ${text}`;
87
+ }).filter(Boolean);
88
+
89
+ const conversationContext = contextParts.join("\n\n");
90
+
91
+ // Combine context + latest question
92
+ const prompt = conversationContext
93
+ ? `Previous conversation:\n${conversationContext}\n\nUser: ${lastUserMessage}`
94
+ : lastUserMessage;
95
+
96
+ return { prompt, systemContext };
97
+ }
98
+
99
+ /**
100
+ * Convert Codex turn response to Anthropic message format
101
+ *
102
+ * @param {Object} turnResult - { text, turnId, raw }
103
+ * @param {string} model - Model name to include in response
104
+ * @returns {Object} Anthropic format response
105
+ */
106
+ function convertCodexResponseToAnthropic(turnResult, model) {
107
+ const text = turnResult.text || "";
108
+
109
+ // Estimate tokens (rough: 1 token ≈ 4 chars)
110
+ const estimatedOutputTokens = Math.ceil(text.length / 4);
111
+
112
+ return {
113
+ id: `msg_codex_${Date.now()}`,
114
+ type: "message",
115
+ role: "assistant",
116
+ model: model || "codex",
117
+ content: [{ type: "text", text }],
118
+ stop_reason: "end_turn",
119
+ stop_sequence: null,
120
+ usage: {
121
+ input_tokens: 0, // Codex doesn't report these via app-server
122
+ output_tokens: estimatedOutputTokens,
123
+ cache_creation_input_tokens: 0,
124
+ cache_read_input_tokens: 0,
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Array.findLastIndex polyfill
131
+ */
132
+ function findLastIndex(arr, fn) {
133
+ for (let i = arr.length - 1; i >= 0; i--) {
134
+ if (fn(arr[i])) return i;
135
+ }
136
+ return -1;
137
+ }
138
+
139
+ module.exports = {
140
+ convertAnthropicToCodexPrompt,
141
+ convertCodexResponseToAnthropic,
142
+ extractText,
143
+ };