@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.99
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/plugins/marketplace.json +20 -0
- package/LICENSE +92 -0
- package/README.md +86 -2
- package/bin/hellolynk-client-setup.mjs +2748 -0
- package/bin/hellolynk-mcp-config-shape.mjs +23 -0
- package/hellolynk-client-pack-manifest.json +619 -0
- package/hellolynk-client-pack-pin.json +4 -0
- package/package.json +58 -4
- package/plugins/hellolynk-codex/.app.json +3 -0
- package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
- package/plugins/hellolynk-codex/.mcp.json +19 -0
- package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/package.json +11 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +160 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
- package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
- package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
- package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
- package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
- package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
- package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
- package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
- package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
- package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
- package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
- package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
- package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
- package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
- package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
- package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
- package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
- package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
- package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
- package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
- package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
- package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
- package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
- package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
- package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
- package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
// The desktop's metadata index, read-only. Never read rollout bodies, previews,
|
|
6
|
+
// first_user_message or model summaries. Native read_thread still decides
|
|
7
|
+
// whether a selected conversation can actually receive a reminder.
|
|
8
|
+
export async function readCodexConversationDirectory({ home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"),
|
|
9
|
+
callerThreadId, projects = [], openDatabase } = {}) {
|
|
10
|
+
const names = (await fs.readdir(home)).filter(name => /^state_\d+\.sqlite$/.test(name))
|
|
11
|
+
.sort((a, b) => Number(b.match(/\d+/)[0]) - Number(a.match(/\d+/)[0]));
|
|
12
|
+
if (!names.length) throw new Error("codex_native_directory_unavailable");
|
|
13
|
+
const Database = openDatabase || ((await import("node:sqlite")).DatabaseSync);
|
|
14
|
+
const db = new Database(path.join(home, names[0]), { readOnly: true });
|
|
15
|
+
try {
|
|
16
|
+
const columns = new Set(db.prepare("PRAGMA table_info(threads)").all().map(row => row.name));
|
|
17
|
+
if (!["id", "title", "cwd", "source", "archived", "creator_account_id"].every(key => columns.has(key)))
|
|
18
|
+
throw new Error("codex_native_directory_schema_unavailable");
|
|
19
|
+
const owner = db.prepare("SELECT creator_account_id FROM threads WHERE id = ?").get(callerThreadId);
|
|
20
|
+
if (!owner) throw new Error("codex_native_directory_owner_unavailable");
|
|
21
|
+
const optional = ["name", "project_id"].filter(key => columns.has(key));
|
|
22
|
+
const rows = db.prepare(`SELECT id, cwd, archived${optional.length ? ", " + optional.join(", ") : ""}
|
|
23
|
+
FROM threads WHERE creator_account_id IS ? AND source IN ('vscode','cli','exec','appServer')`).all(owner.creator_account_id);
|
|
24
|
+
const byProject = new Map(projects.map(project => [project.id || project.projectId, project.name || project.title]));
|
|
25
|
+
return { coverage: "complete", exactTargetSupported: true, conversations: rows.map(row => ({
|
|
26
|
+
// In the native index, title can be the complete first user message.
|
|
27
|
+
// Only the explicit conversation name is metadata safe to export.
|
|
28
|
+
targetConversationId: row.id, title: row.name || row.id,
|
|
29
|
+
project: byProject.get(row.project_id) || path.win32.basename(row.cwd || "") || path.posix.basename(row.cwd || ""),
|
|
30
|
+
state: row.archived ? "archived" : "notLoaded",
|
|
31
|
+
})) };
|
|
32
|
+
} finally { db.close(); }
|
|
33
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export { rememberNativeReplyRoute, readNativeReplyRoute } from "../runtime/core/hellolynk-native-reply-routes.mjs";
|
|
2
|
+
import { nativeReplyRouteLocation } from "../runtime/core/hellolynk-native-reply-routes.mjs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
const validCaller = value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_.:-]{2,240}$/.test(value);
|
|
8
|
+
|
|
9
|
+
// This is the executor's previously observed source, not a delivery destination.
|
|
10
|
+
// Keep it beside the existing routes, scoped to the same HelloLynk identity.
|
|
11
|
+
export async function rememberNativeCaller(scope, threadId) {
|
|
12
|
+
if (!validCaller(threadId)) throw new Error("native_caller_invalid");
|
|
13
|
+
const { directory } = nativeReplyRouteLocation(scope);
|
|
14
|
+
await fs.mkdir(directory, { recursive: true });
|
|
15
|
+
const file = path.join(directory, "caller.json");
|
|
16
|
+
const temporary = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
17
|
+
try {
|
|
18
|
+
await fs.writeFile(temporary, JSON.stringify({ threadId }), { flag: "wx" });
|
|
19
|
+
await fs.rename(temporary, file);
|
|
20
|
+
} finally { await fs.unlink(temporary).catch(() => {}); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readNativeCaller(scope) {
|
|
24
|
+
const { directory } = nativeReplyRouteLocation(scope);
|
|
25
|
+
try {
|
|
26
|
+
const record = JSON.parse(await fs.readFile(path.join(directory, "caller.json"), "utf8"));
|
|
27
|
+
return validCaller(record?.threadId) ? record.threadId : null;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error.code === "ENOENT" || error instanceof SyntaxError) return null;
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { readCodexConversationDirectory } from "./native-conversation-directory.mjs";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
|
|
5
|
+
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
const ALLOWED = new Set(["create_thread", "send_message_to_thread", "read_thread", "list_threads", "list_projects", "list_archived_threads", "wait_threads", "open_in_codex"]);
|
|
7
|
+
|
|
8
|
+
export function createNativeTaskClient({ env = process.env, connect = net.createConnection,
|
|
9
|
+
timeoutMs = 30_000, connectTimeoutMs = 10_000, executorMetadata = null, registeredCallerThreadId = null } = {}) {
|
|
10
|
+
const pipePath = env.CODEX_APP_TOOLS_PIPE_PATH?.trim();
|
|
11
|
+
const environmentThreadId = env.CODEX_THREAD_ID?.trim();
|
|
12
|
+
const metadataContext = inspectCodexExecutorMetadata(executorMetadata);
|
|
13
|
+
if (metadataContext.conflict) return null;
|
|
14
|
+
const callerThreadId = metadataContext.threadId || environmentThreadId || registeredCallerThreadId;
|
|
15
|
+
if (!pipePath || !callerThreadId) return null;
|
|
16
|
+
let callerVerified = Boolean(metadataContext.threadId || environmentThreadId);
|
|
17
|
+
let socket = null;
|
|
18
|
+
let connecting = null;
|
|
19
|
+
let connectingSocket = null;
|
|
20
|
+
let rejectConnecting = null;
|
|
21
|
+
let closed = false;
|
|
22
|
+
let generation = 0;
|
|
23
|
+
let nextId = 1;
|
|
24
|
+
let catalog = null;
|
|
25
|
+
let archivedPages = null;
|
|
26
|
+
const pending = new Map();
|
|
27
|
+
const isCurrentConnection = (connection) => !closed && connection?.socket === socket
|
|
28
|
+
&& socket && !socket.destroyed && connection.generation === generation;
|
|
29
|
+
const assertCurrentConnection = (connection) => {
|
|
30
|
+
if (closed) throw new Error("native_client_closed");
|
|
31
|
+
if (!isCurrentConnection(connection)) throw new Error("native_pipe_changed");
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const failSocket = (candidate, error) => {
|
|
35
|
+
if (socket !== candidate) return; // A late close from an old pipe cannot cancel a new request.
|
|
36
|
+
socket = null;
|
|
37
|
+
catalog = null;
|
|
38
|
+
archivedPages?.clear();
|
|
39
|
+
for (const [id, item] of pending) {
|
|
40
|
+
if (item.generation !== generation) continue;
|
|
41
|
+
pending.delete(id);
|
|
42
|
+
item.reject(error);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const connectOnce = async () => {
|
|
46
|
+
if (closed) throw new Error("native_client_closed");
|
|
47
|
+
if (socket && !socket.destroyed) return;
|
|
48
|
+
if (connecting) return connecting;
|
|
49
|
+
connecting = new Promise((resolve, reject) => {
|
|
50
|
+
const candidate = connect(pipePath);
|
|
51
|
+
connectingSocket = candidate;
|
|
52
|
+
let settled = false;
|
|
53
|
+
const finish = (error) => {
|
|
54
|
+
if (settled) return;
|
|
55
|
+
settled = true;
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
candidate.off("error", onError);
|
|
58
|
+
candidate.off("close", onClose);
|
|
59
|
+
connectingSocket = null;
|
|
60
|
+
rejectConnecting = null;
|
|
61
|
+
if (error) { candidate.destroy(); reject(error); }
|
|
62
|
+
else resolve();
|
|
63
|
+
};
|
|
64
|
+
const onError = () => finish(new Error("native_connect_failed"));
|
|
65
|
+
const onClose = () => finish(new Error("native_connect_closed"));
|
|
66
|
+
rejectConnecting = (error) => finish(error);
|
|
67
|
+
const timeout = setTimeout(() => finish(new Error("native_connect_timeout")), connectTimeoutMs);
|
|
68
|
+
timeout.unref?.();
|
|
69
|
+
candidate.once("error", onError);
|
|
70
|
+
candidate.once("close", onClose);
|
|
71
|
+
candidate.once("connect", () => {
|
|
72
|
+
if (closed) { finish(new Error("native_client_closed")); return; }
|
|
73
|
+
candidate.off("error", onError);
|
|
74
|
+
candidate.off("close", onClose);
|
|
75
|
+
socket = candidate;
|
|
76
|
+
generation += 1;
|
|
77
|
+
const socketGeneration = generation;
|
|
78
|
+
let buffer = Buffer.alloc(0);
|
|
79
|
+
candidate.on("data", (chunk) => {
|
|
80
|
+
if (socket !== candidate || generation !== socketGeneration) return;
|
|
81
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
82
|
+
while (buffer.length >= 4) {
|
|
83
|
+
const length = buffer.readUInt32LE(0);
|
|
84
|
+
if (length > MAX_FRAME_BYTES) { candidate.destroy(); failSocket(candidate, new Error("native_frame_too_large")); return; }
|
|
85
|
+
if (buffer.length < length + 4) return;
|
|
86
|
+
let response;
|
|
87
|
+
try { response = JSON.parse(buffer.subarray(4, length + 4).toString("utf8")); }
|
|
88
|
+
catch { candidate.destroy(); failSocket(candidate, new Error("native_frame_invalid")); return; }
|
|
89
|
+
buffer = buffer.subarray(length + 4);
|
|
90
|
+
if (response?.jsonrpc !== "2.0" || !Number.isSafeInteger(Number(response.id))) {
|
|
91
|
+
candidate.destroy(); failSocket(candidate, new Error("native_response_invalid")); return;
|
|
92
|
+
}
|
|
93
|
+
const id = Number(response.id);
|
|
94
|
+
const waiter = pending.get(id);
|
|
95
|
+
if (!waiter || waiter.generation !== socketGeneration) continue;
|
|
96
|
+
pending.delete(id);
|
|
97
|
+
if (response.error) waiter.reject(new Error("native_tool_error"));
|
|
98
|
+
else waiter.resolve(response.result);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
candidate.on("error", () => failSocket(candidate, new Error("native_pipe_error")));
|
|
102
|
+
candidate.on("close", () => failSocket(candidate, new Error("native_pipe_closed")));
|
|
103
|
+
finish();
|
|
104
|
+
});
|
|
105
|
+
}).finally(() => { connecting = null; });
|
|
106
|
+
return connecting;
|
|
107
|
+
};
|
|
108
|
+
const request = async (method, params, connection, beforeWrite) => {
|
|
109
|
+
// Discovery and the eventual byte-write must use the very same pipe.
|
|
110
|
+
// Never reconnect inside a call carrying a previously selected namespace.
|
|
111
|
+
assertCurrentConnection(connection);
|
|
112
|
+
const candidate = connection.socket;
|
|
113
|
+
const requestGeneration = connection.generation;
|
|
114
|
+
const id = nextId++;
|
|
115
|
+
const requestParams = method === "tools/call" ? { ...params, turnId: `mcp-turn-${id}` } : params;
|
|
116
|
+
const payload = Buffer.from(JSON.stringify({ jsonrpc: "2.0", id, method, params: requestParams }), "utf8");
|
|
117
|
+
if (payload.length > MAX_FRAME_BYTES) throw new Error("native_request_too_large");
|
|
118
|
+
const frame = Buffer.alloc(4 + payload.length);
|
|
119
|
+
frame.writeUInt32LE(payload.length, 0);
|
|
120
|
+
payload.copy(frame, 4);
|
|
121
|
+
const result = new Promise((resolve, reject) => {
|
|
122
|
+
const timeout = setTimeout(() => {
|
|
123
|
+
pending.delete(id);
|
|
124
|
+
reject(new Error("native_request_timeout"));
|
|
125
|
+
}, timeoutMs);
|
|
126
|
+
timeout.unref?.();
|
|
127
|
+
pending.set(id, {
|
|
128
|
+
generation: requestGeneration,
|
|
129
|
+
resolve(value) { clearTimeout(timeout); resolve(value); },
|
|
130
|
+
reject(error) { clearTimeout(timeout); reject(error); },
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
assertCurrentConnection(connection);
|
|
135
|
+
beforeWrite?.();
|
|
136
|
+
candidate.write(frame);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
pending.get(id)?.reject(error);
|
|
139
|
+
pending.delete(id);
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
};
|
|
143
|
+
const list = async () => {
|
|
144
|
+
if (isCurrentConnection(catalog)) return catalog;
|
|
145
|
+
await connectOnce();
|
|
146
|
+
const connection = { socket, generation };
|
|
147
|
+
const result = await request("tools/list", { threadStartKind: "all" }, connection);
|
|
148
|
+
// A valid frame can be followed by close/bad-frame before this resumes.
|
|
149
|
+
assertCurrentConnection(connection);
|
|
150
|
+
if (!Array.isArray(result?.tools)) throw new Error("native_catalog_invalid");
|
|
151
|
+
catalog = { ...connection, tools: new Map(result.tools.filter((tool) => typeof tool?.name === "string" && typeof tool?.namespace === "string")
|
|
152
|
+
.map((tool) => [tool.name, tool.namespace])) };
|
|
153
|
+
return catalog;
|
|
154
|
+
};
|
|
155
|
+
const client = {
|
|
156
|
+
callerThreadId,
|
|
157
|
+
beforeTick() { archivedPages = new Map(); },
|
|
158
|
+
async ensureTools(names) {
|
|
159
|
+
const available = await list();
|
|
160
|
+
assertCurrentConnection(available);
|
|
161
|
+
if (!Array.isArray(names) || names.some((name) => !ALLOWED.has(name) || !available.tools.has(name)))
|
|
162
|
+
throw new Error("native_tool_unavailable");
|
|
163
|
+
if (!callerVerified) {
|
|
164
|
+
// Runtime calls ensureTools before acquiring its worker lease. A window
|
|
165
|
+
// that is still starting can retry on the existing tick without owning
|
|
166
|
+
// delivery or needing a new model tool call.
|
|
167
|
+
const read = await client.call("read_thread", { threadId: callerThreadId, hostId: "local", turnLimit: 1, includeOutputs: false });
|
|
168
|
+
assertCurrentConnection(available);
|
|
169
|
+
if (read?.thread?.id !== callerThreadId || read.thread.kind !== "codex" || read.thread.hostId !== "local")
|
|
170
|
+
throw new Error("native_caller_unavailable");
|
|
171
|
+
callerVerified = true;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
async call(tool, args, context) {
|
|
175
|
+
let dispatched = false;
|
|
176
|
+
try {
|
|
177
|
+
if (!ALLOWED.has(tool)) throw new Error("native_tool_disallowed");
|
|
178
|
+
const available = await list();
|
|
179
|
+
assertCurrentConnection(available);
|
|
180
|
+
const namespace = available.tools.get(tool);
|
|
181
|
+
if (!namespace) throw new Error("native_tool_unavailable");
|
|
182
|
+
const dispatch = tool === "create_thread" || tool === "send_message_to_thread";
|
|
183
|
+
// Discovery/reconnect may outlive receiver stop or an account change.
|
|
184
|
+
// Recheck after that wait; no native request has been written yet.
|
|
185
|
+
if (dispatch && context?.stillCurrent && !await context.stillCurrent()) throw new Error("native_scope_changed");
|
|
186
|
+
const result = await request("tools/call", {
|
|
187
|
+
arguments: args, callId: `mcp-call-${crypto.randomUUID()}`, namespace,
|
|
188
|
+
callerSource: "codex", threadId: callerThreadId, tool,
|
|
189
|
+
}, available, () => {
|
|
190
|
+
// Synchronous fence adjacent to write(), including stop while an
|
|
191
|
+
// asynchronous scope check or argument serialization was pending.
|
|
192
|
+
if (dispatch && context?.isStopped?.()) throw new Error("native_scope_changed");
|
|
193
|
+
dispatched = true;
|
|
194
|
+
});
|
|
195
|
+
if (result?.success !== true || !Array.isArray(result.contentItems)) {
|
|
196
|
+
// Only the host's exact not-found response is a deletion proof. A
|
|
197
|
+
// timeout, unavailable host or unreadable result must keep the mapping.
|
|
198
|
+
const message = result?.contentItems?.filter((item) => item.type === "inputText").map((item) => item.text).join("\n");
|
|
199
|
+
if (tool === "read_thread" && args?.hostId === "local" && typeof args.threadId === "string" && localThreadMissing(message, args.threadId))
|
|
200
|
+
throw new Error("native_thread_deleted");
|
|
201
|
+
throw new Error("native_tool_result_unknown");
|
|
202
|
+
}
|
|
203
|
+
const text = result.contentItems.filter((item) => item.type === "inputText").map((item) => item.text).join("\n");
|
|
204
|
+
try {
|
|
205
|
+
const data = JSON.parse(text);
|
|
206
|
+
return data && typeof data === "object" && !Array.isArray(data)
|
|
207
|
+
? { ...data, _nativeToolSuccess: true } : { value: data, _nativeToolSuccess: true };
|
|
208
|
+
} catch { return { text, _nativeToolSuccess: true }; }
|
|
209
|
+
} catch (error) {
|
|
210
|
+
// Discovery/connection failures cannot have executed this tool. Once
|
|
211
|
+
// write() starts, even a synchronous error may be an unknown result.
|
|
212
|
+
if (dispatched) throw error;
|
|
213
|
+
// Socket failure objects can be shared by several pending requests.
|
|
214
|
+
// The delivery classification belongs only to this invocation.
|
|
215
|
+
const failure = new Error(error?.message || "native_call_failed", { cause: error });
|
|
216
|
+
if (error?.code) failure.code = error.code;
|
|
217
|
+
failure.nativeDispatchStarted = false;
|
|
218
|
+
throw failure;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
async findCreatedThread(prompt, sourceThreadId = callerThreadId) {
|
|
222
|
+
const escape = text => text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
223
|
+
const expected = `<codex_delegation>\n <source_thread_id>${escape(sourceThreadId)}</source_thread_id>\n <input>${escape(prompt)}</input>\n</codex_delegation>`;
|
|
224
|
+
if (expected.length > 20_000) return null; // Host output projection ceiling.
|
|
225
|
+
const listed = await client.call("list_threads", { limit: 50 });
|
|
226
|
+
if (!Array.isArray(listed?.pinnedThreads) || !Array.isArray(listed?.threads))
|
|
227
|
+
throw new Error("native_thread_lifecycle_unknown");
|
|
228
|
+
const candidates = new Map([...listed.pinnedThreads, ...listed.threads]
|
|
229
|
+
.filter(thread => thread.kind === "codex" && thread.hostId === "local"
|
|
230
|
+
&& typeof thread.title === "string" && /^HelloLynk[\s·•::-]*独立会话$/u.test(thread.title) && typeof thread.id === "string")
|
|
231
|
+
.map(thread => [thread.id, thread]));
|
|
232
|
+
const matches = [];
|
|
233
|
+
for (const threadId of candidates.keys()) {
|
|
234
|
+
let read;
|
|
235
|
+
try { read = await client.call("read_thread", { threadId, hostId: "local", turnLimit: 10,
|
|
236
|
+
includeOutputs: true, maxOutputCharsPerItem: expected.length }); }
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (error?.message === "native_thread_deleted") continue;
|
|
239
|
+
throw error; // Do not repeat an unavailable transport for every candidate.
|
|
240
|
+
}
|
|
241
|
+
// New unknown creates normally have one turn. Do not walk an old
|
|
242
|
+
// conversation to find a match: a partial page cannot prove its origin.
|
|
243
|
+
if (read?.thread?.id !== threadId || read.thread.kind !== "codex" || read.thread.hostId !== "local"
|
|
244
|
+
|| read.page?.order !== "newest_first" || read.page.hasMore !== false || !Array.isArray(read.turns)) continue;
|
|
245
|
+
const first = read.turns.at(-1)?.items?.[0];
|
|
246
|
+
const userMatch = first?.type === "userMessage" && first.content?.length === 1
|
|
247
|
+
&& first.content[0].type === "text" && first.content[0].text === expected;
|
|
248
|
+
const toolMatch = first?.type === "functionCallOutput" && first.name === "create_thread"
|
|
249
|
+
&& first.namespace === "codex_app" && first.output?.truncated === false && first.output.text === expected;
|
|
250
|
+
if (userMatch || toolMatch) matches.push(threadId);
|
|
251
|
+
}
|
|
252
|
+
return matches.length === 1 ? { threadId: matches[0] } : null;
|
|
253
|
+
},
|
|
254
|
+
async listConversations(query = {}) {
|
|
255
|
+
let projects = [];
|
|
256
|
+
try { const value = await client.call("list_projects", {}); projects = value.projects || []; } catch {}
|
|
257
|
+
let directory;
|
|
258
|
+
try { directory = await readCodexConversationDirectory({ home: env.CODEX_HOME, callerThreadId, projects }); }
|
|
259
|
+
catch {
|
|
260
|
+
const value = await client.call("list_threads", { limit: 1000 });
|
|
261
|
+
directory = { coverage: "partial", exactTargetSupported: true,
|
|
262
|
+
conversations: [...(value.pinnedThreads || []), ...(value.threads || [])]
|
|
263
|
+
.filter(row => row.kind === "codex" && row.hostId === "local").map(row => ({
|
|
264
|
+
targetConversationId: row.id, title: row.name || row.id, project: row.project?.name || row.projectName || "",
|
|
265
|
+
state: row.status?.type || row.status || "notLoaded" })) };
|
|
266
|
+
}
|
|
267
|
+
if (query.targetConversationId) {
|
|
268
|
+
const state = await client.inspectThread(query.targetConversationId);
|
|
269
|
+
const row = directory.conversations.find(row => row.targetConversationId === query.targetConversationId);
|
|
270
|
+
const lifecycle = ["archived", "deleted"].includes(state.state) ? state.state : state.thread?.status?.type || state.thread?.status || "unavailable";
|
|
271
|
+
if (row) row.state = lifecycle;
|
|
272
|
+
else directory.conversations.push({ targetConversationId: query.targetConversationId,
|
|
273
|
+
title: state.thread?.name || query.targetConversationId, project: "", state: lifecycle });
|
|
274
|
+
}
|
|
275
|
+
return directory;
|
|
276
|
+
},
|
|
277
|
+
async inspectThread(threadId) {
|
|
278
|
+
// The read_thread projection omits archive state. Read the host's archive
|
|
279
|
+
// metadata first; never infer deletion from a truncated recent list.
|
|
280
|
+
let cursor;
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
for (let page = 0; page < 100; page += 1) {
|
|
283
|
+
// All destinations in one receiver tick share the same archive pages.
|
|
284
|
+
// The next tick or a lost connection discards them; live status and
|
|
285
|
+
// exact-thread reads below remain fresh for each destination.
|
|
286
|
+
const pageKey = cursor || "";
|
|
287
|
+
let reading = archivedPages?.get(pageKey);
|
|
288
|
+
if (!reading) {
|
|
289
|
+
reading = client.call("list_archived_threads", { hostId: "local", limit: 50, ...(cursor ? { cursor } : {}) });
|
|
290
|
+
archivedPages?.set(pageKey, reading);
|
|
291
|
+
}
|
|
292
|
+
const result = await reading;
|
|
293
|
+
if (!Array.isArray(result?.threads)) throw new Error("native_thread_lifecycle_unknown");
|
|
294
|
+
if (result.threads.some((entry) => entry.id === threadId && entry.kind === "codex")) return { state: "archived" };
|
|
295
|
+
if (result.nextCursor == null) {
|
|
296
|
+
try {
|
|
297
|
+
// Availability is runtime metadata. Do not make notification depend
|
|
298
|
+
// on fetching conversation history (which can be separately limited).
|
|
299
|
+
const available = await list();
|
|
300
|
+
if (available.tools.has("list_threads")) {
|
|
301
|
+
// This immediate metadata call works on the app-tools pipe.
|
|
302
|
+
// wait_threads is advertised there but can end without a result.
|
|
303
|
+
const snapshot = await client.call("list_threads", { limit: 50 });
|
|
304
|
+
if (!Array.isArray(snapshot?.pinnedThreads) || !Array.isArray(snapshot?.threads))
|
|
305
|
+
throw new Error("native_thread_lifecycle_unknown");
|
|
306
|
+
const current = [...snapshot.pinnedThreads, ...snapshot.threads].find((entry) =>
|
|
307
|
+
entry.id === threadId && entry.hostId === "local" && entry.kind === "codex");
|
|
308
|
+
if (current) return { state: "present", thread: current };
|
|
309
|
+
// The list holds only pinned plus the 50 most recent threads and
|
|
310
|
+
// has no cursor, so absence proves nothing: ask for this thread.
|
|
311
|
+
}
|
|
312
|
+
const current = await client.call("read_thread", { threadId, hostId: "local", turnLimit: 1, includeOutputs: false });
|
|
313
|
+
if (current?.thread?.id !== threadId || (current.thread.hostId != null && current.thread.hostId !== "local"))
|
|
314
|
+
throw new Error("native_thread_lifecycle_unknown");
|
|
315
|
+
// This adapter owns the lifecycle label; unrelated host metadata
|
|
316
|
+
// named state must not replace the exact-thread lookup result.
|
|
317
|
+
return { ...current, state: "present" };
|
|
318
|
+
} catch (error) { if (error.message === "native_thread_deleted") return { state: "deleted" }; throw error; }
|
|
319
|
+
}
|
|
320
|
+
if (typeof result.nextCursor !== "string" || !result.nextCursor || seen.has(result.nextCursor)) break;
|
|
321
|
+
cursor = result.nextCursor; seen.add(cursor);
|
|
322
|
+
}
|
|
323
|
+
throw new Error("native_thread_lifecycle_unknown");
|
|
324
|
+
},
|
|
325
|
+
close() {
|
|
326
|
+
if (closed) return;
|
|
327
|
+
closed = true;
|
|
328
|
+
rejectConnecting?.(new Error("native_client_closed"));
|
|
329
|
+
connectingSocket?.destroy();
|
|
330
|
+
const current = socket;
|
|
331
|
+
socket = null;
|
|
332
|
+
archivedPages = null;
|
|
333
|
+
current?.destroy();
|
|
334
|
+
for (const item of pending.values()) item.reject(new Error("native_client_closed"));
|
|
335
|
+
pending.clear();
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
return Object.freeze(client);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Our origins and carriers are local threads. Codex asks the preferred local
|
|
342
|
+
// host first and lists it as failed only when that lookup itself failed, so a
|
|
343
|
+
// failed cloud host beside a local not-found still proves the thread is gone.
|
|
344
|
+
function localThreadMissing(message, threadId) {
|
|
345
|
+
if (typeof message !== "string") return false;
|
|
346
|
+
const notFound = `No Codex thread found for threadId: ${threadId}`;
|
|
347
|
+
if (message === notFound) return true;
|
|
348
|
+
const hosts = (prefix) => {
|
|
349
|
+
if (!message.startsWith(prefix)) return null;
|
|
350
|
+
const entries = message.slice(prefix.length).split(", ");
|
|
351
|
+
// Unexpected punctuation/whitespace is not the host's known error format.
|
|
352
|
+
return entries.every((entry) => entry.length > 0 && !/[^a-z0-9_.:-]/.test(entry)) ? entries : null;
|
|
353
|
+
};
|
|
354
|
+
const unmatched = hosts(`${notFound}. Hosts without a readable match: `);
|
|
355
|
+
if (unmatched) return unmatched.includes("local");
|
|
356
|
+
const failed = hosts(`Could not determine whether Codex thread ${threadId} exists. Unavailable or failed hosts: `);
|
|
357
|
+
return failed != null && !failed.includes("local");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Only call this with params._meta from an executor-owned MCP tools/call
|
|
361
|
+
// request. Tool arguments and external message bodies are never sources of
|
|
362
|
+
// caller identity. These fields match the installed Codex app-tools bridge.
|
|
363
|
+
export function readCodexExecutorThreadId(metadata) {
|
|
364
|
+
return inspectCodexExecutorMetadata(metadata).threadId;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function inspectCodexExecutorMetadata(metadata) {
|
|
368
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) return { threadId: null, conflict: false };
|
|
369
|
+
const ids = new Set();
|
|
370
|
+
const add = (value) => { if (typeof value === "string" && value.trim()) ids.add(value.trim()); };
|
|
371
|
+
for (const key of ["openai/threadId", "openai/thread_id", "codexThreadId", "codex_thread_id", "threadId", "thread_id"]) {
|
|
372
|
+
add(metadata[key]);
|
|
373
|
+
}
|
|
374
|
+
let turn = metadata["x-codex-turn-metadata"];
|
|
375
|
+
if (typeof turn === "string") {
|
|
376
|
+
try { turn = JSON.parse(turn); } catch { turn = null; }
|
|
377
|
+
}
|
|
378
|
+
if (turn && typeof turn === "object" && !Array.isArray(turn)) add(turn.thread_id);
|
|
379
|
+
const nested = metadata.thread;
|
|
380
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) add(nested.id);
|
|
381
|
+
return { threadId: ids.size === 1 ? [...ids][0] : null, conflict: ids.size > 1 };
|
|
382
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { createNativeInboxRuntime } from "../runtime/core/hellolynk-native-inbox-runtime.mjs";
|
|
2
|
+
import { createNativeTaskClient } from "./native-task-client.mjs";
|
|
3
|
+
import { readNativeReplyRoute } from "./native-reply-routes.mjs";
|
|
4
|
+
|
|
5
|
+
export function createNativeTaskRuntime(options = {}) {
|
|
6
|
+
return createNativeInboxRuntime({ native: createNativeTaskClient(), readReplyRoute: readNativeReplyRoute,
|
|
7
|
+
...options, adapterId: "codex" });
|
|
8
|
+
}
|