A model picked on the phone only arrives with a typed message that starts a turn. The bridge now handles "modeli X yap" itself: per-chat routing, same-turn handover when a model is working, truthful answer to "which model", and a lone "." joining a running turn no longer cuts the work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
/**
|
|
* Codex Link ("Codex Bağlama") — the pure half.
|
|
*
|
|
* AITURK IDE ships a small local Python bridge that lets the OpenAI Codex
|
|
* installed on this machine use the IDE's models and combos. Turning the
|
|
* feature on means editing a file the IDE does not own (Codex's `config.toml`)
|
|
* and registering a login item, so every text transformation lives here as a
|
|
* dependency-free, byte-preserving function that can be proven without
|
|
* Electron, a filesystem, or a registry. `codex-link.ts` owns the decisions and
|
|
* the side effects.
|
|
*
|
|
* Invariants:
|
|
* - Only the line carrying OUR trailing comment is ever written or removed.
|
|
* - An `openai_base_url` that is not ours means someone else's bridge is in
|
|
* charge: report `foreign` and change nothing.
|
|
* - Everything else in the file — including its line endings and BOM — comes
|
|
* back byte for byte.
|
|
*/
|
|
|
|
export const CODEX_LINK_PORT = 8795
|
|
export const CODEX_LINK_ORIGIN = `http://127.0.0.1:${CODEX_LINK_PORT}`
|
|
export const CODEX_LINK_STATUS_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/status`
|
|
export const CODEX_LINK_STOP_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/stop`
|
|
export const CODEX_LINK_REFRESH_URL = `${CODEX_LINK_ORIGIN}/aiturk/codex-baglama/refresh`
|
|
|
|
/** The trailing comment that marks the line as ours. */
|
|
export const CODEX_LINK_MARKER = '# AITURK IDE Codex Baglama'
|
|
// Must equal SURUM in assets/codex-bridge/bridge.py; a running bridge that reports
|
|
// another version is restarted so an updated IDE never keeps an old bridge alive.
|
|
export const CODEX_BRIDGE_VERSION = '8'
|
|
export const CODEX_LINK_LINE = `openai_base_url = "${CODEX_LINK_ORIGIN}/backend-api/codex" ${CODEX_LINK_MARKER}`
|
|
|
|
export const CODEX_LINK_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run'
|
|
export const CODEX_LINK_RUN_VALUE = 'AITURK-IDE-Codex-Baglama'
|
|
export const CODEX_LINK_LAUNCHER_NAME = 'baslat.pyw'
|
|
|
|
// Windows documents Run-key command lines as "no longer than 260 characters".
|
|
// A longer value is not reliably started at sign-in, so the direct command is
|
|
// only used when it fits (see autostartPlan).
|
|
export const CODEX_LINK_RUN_COMMAND_MAX = 260
|
|
|
|
/** Who owns the top-level `openai_base_url` in a Codex config. */
|
|
export type CodexConfigOwner = 'absent' | 'foreign' | 'ours'
|
|
|
|
interface Line {
|
|
eol: string
|
|
text: string
|
|
}
|
|
|
|
const BOM = '\uFEFF'
|
|
const KEY_PATTERN = /^[ \t]*(?:openai_base_url|"openai_base_url"|'openai_base_url')[ \t]*=/
|
|
|
|
// Split into lines WITHOUT losing anything: joining `text + eol` of every entry
|
|
// reproduces the input exactly, whatever mix of \n, \r\n and \r it used.
|
|
function splitLines(source: string): Line[] {
|
|
const lines: Line[] = []
|
|
let start = 0
|
|
|
|
while (start < source.length) {
|
|
let end = start
|
|
|
|
while (end < source.length && source[end] !== '\n' && source[end] !== '\r') {
|
|
end += 1
|
|
}
|
|
|
|
let eol = ''
|
|
|
|
if (end < source.length) {
|
|
eol = source[end] === '\r' && source[end + 1] === '\n' ? '\r\n' : source[end]
|
|
}
|
|
|
|
lines.push({ eol, text: source.slice(start, end) })
|
|
start = end + eol.length
|
|
}
|
|
|
|
return lines
|
|
}
|
|
|
|
function joinLines(lines: Line[]): string {
|
|
return lines.map(line => line.text + line.eol).join('')
|
|
}
|
|
|
|
function splitBom(source: string): { body: string; bom: string } {
|
|
return source.startsWith(BOM) ? { body: source.slice(1), bom: BOM } : { body: source, bom: '' }
|
|
}
|
|
|
|
type MultilineQuote = "'''" | '"""' | null
|
|
|
|
interface ValueState {
|
|
depth: number
|
|
multiline: MultilineQuote
|
|
}
|
|
|
|
// Index just past the `=` of a `key = value` line, or the line length when the
|
|
// line holds no value (blank, comment, malformed). Quoted keys may contain `=`.
|
|
function valueStart(text: string): number {
|
|
let pos = 0
|
|
|
|
while (pos < text.length) {
|
|
const char = text[pos]
|
|
|
|
if (char === '#') {
|
|
return text.length
|
|
}
|
|
|
|
if (char === '=') {
|
|
return pos + 1
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
pos += 1
|
|
|
|
while (pos < text.length && text[pos] !== char) {
|
|
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
|
}
|
|
}
|
|
|
|
pos += 1
|
|
}
|
|
|
|
return text.length
|
|
}
|
|
|
|
// Walk a value just far enough to know whether the NEXT line is still inside it
|
|
// (multi-line string or array). That is the only TOML the top-level scan needs:
|
|
// without it, text inside a multi-line value could pass for a key or a table
|
|
// header.
|
|
function scanValue(text: string, from: number, state: ValueState): ValueState {
|
|
let { depth, multiline } = state
|
|
let pos = from
|
|
|
|
while (pos < text.length) {
|
|
if (multiline) {
|
|
if (multiline === '"""' && text[pos] === '\\') {
|
|
pos += 2
|
|
|
|
continue
|
|
}
|
|
|
|
if (text.startsWith(multiline, pos)) {
|
|
pos += 3
|
|
|
|
// Up to two quotes may sit directly before the closing delimiter.
|
|
while (text[pos] === multiline[0]) {
|
|
pos += 1
|
|
}
|
|
|
|
multiline = null
|
|
|
|
continue
|
|
}
|
|
|
|
pos += 1
|
|
|
|
continue
|
|
}
|
|
|
|
const char = text[pos]
|
|
|
|
if (char === '#') {
|
|
break
|
|
}
|
|
|
|
if (text.startsWith('"""', pos) || text.startsWith("'''", pos)) {
|
|
multiline = text.slice(pos, pos + 3) as MultilineQuote
|
|
pos += 3
|
|
|
|
continue
|
|
}
|
|
|
|
if (char === '"' || char === "'") {
|
|
pos += 1
|
|
|
|
while (pos < text.length && text[pos] !== char) {
|
|
pos += char === '"' && text[pos] === '\\' ? 2 : 1
|
|
}
|
|
|
|
pos += 1
|
|
|
|
continue
|
|
}
|
|
|
|
if (char === '[' || char === '{') {
|
|
depth += 1
|
|
} else if (char === ']' || char === '}') {
|
|
depth = Math.max(0, depth - 1)
|
|
}
|
|
|
|
pos += 1
|
|
}
|
|
|
|
return { depth, multiline }
|
|
}
|
|
|
|
// Indices of the top-level `openai_base_url` lines: those before the first
|
|
// `[table]` header, outside comments and multi-line values.
|
|
function topLevelEntries(lines: Line[]): { index: number; ours: boolean }[] {
|
|
const entries: { index: number; ours: boolean }[] = []
|
|
let state: ValueState = { depth: 0, multiline: null }
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const { text } = lines[index]
|
|
let from = 0
|
|
|
|
if (!state.multiline && state.depth === 0) {
|
|
if (text.trimStart().startsWith('[')) {
|
|
break
|
|
}
|
|
|
|
if (KEY_PATTERN.test(text)) {
|
|
// Ours by marker or by value: Codex may rewrite its config and drop the comment.
|
|
entries.push({
|
|
index,
|
|
ours: text.includes(CODEX_LINK_MARKER) || text.includes(`"${CODEX_LINK_ORIGIN}/backend-api/codex"`)
|
|
})
|
|
}
|
|
|
|
from = valueStart(text)
|
|
}
|
|
|
|
state = scanValue(text, from, state)
|
|
}
|
|
|
|
return entries
|
|
}
|
|
|
|
function ownerOf(entries: { ours: boolean }[]): CodexConfigOwner {
|
|
if (entries.some(entry => !entry.ours)) {
|
|
return 'foreign'
|
|
}
|
|
|
|
return entries.length > 0 ? 'ours' : 'absent'
|
|
}
|
|
|
|
/** Who owns the top-level `openai_base_url` of this config text. */
|
|
export function inspectCodexConfig(source: string): CodexConfigOwner {
|
|
return ownerOf(topLevelEntries(splitLines(splitBom(source).body)))
|
|
}
|
|
|
|
export interface CodexConfigEdit {
|
|
changed: boolean
|
|
owner: CodexConfigOwner
|
|
text: string
|
|
}
|
|
|
|
/**
|
|
* Point Codex at the bridge. Idempotent; a foreign `openai_base_url` leaves the
|
|
* text untouched and reports `foreign`.
|
|
*
|
|
* The line goes on the very first line: top-level keys must precede the first
|
|
* table header, and the top of the file is the one position that is always
|
|
* top-level without having to understand the values below it.
|
|
*/
|
|
export function addCodexLinkLine(source: string): CodexConfigEdit {
|
|
const { body, bom } = splitBom(source)
|
|
const lines = splitLines(body)
|
|
const entries = topLevelEntries(lines)
|
|
const owner = ownerOf(entries)
|
|
|
|
if (owner === 'foreign') {
|
|
return { changed: false, owner, text: source }
|
|
}
|
|
|
|
if (owner === 'ours') {
|
|
const [first, ...duplicates] = entries
|
|
|
|
if (duplicates.length === 0 && lines[first.index].text === CODEX_LINK_LINE) {
|
|
return { changed: false, owner, text: source }
|
|
}
|
|
|
|
// A stale copy of our line (older address) or an accidental duplicate:
|
|
// normalize to exactly one current line, in place.
|
|
const drop = new Set(duplicates.map(entry => entry.index))
|
|
lines[first.index] = { ...lines[first.index], text: CODEX_LINK_LINE }
|
|
|
|
return { changed: true, owner, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
|
}
|
|
|
|
const eol = body.includes('\r\n') ? '\r\n' : '\n'
|
|
|
|
return { changed: true, owner: 'ours', text: `${bom}${CODEX_LINK_LINE}${eol}${body}` }
|
|
}
|
|
|
|
/** Remove our line (and only ours). A foreign key is left exactly as it was. */
|
|
export function removeCodexLinkLine(source: string): CodexConfigEdit {
|
|
const { body, bom } = splitBom(source)
|
|
const lines = splitLines(body)
|
|
const entries = topLevelEntries(lines)
|
|
const drop = new Set(entries.filter(entry => entry.ours).map(entry => entry.index))
|
|
const remaining = ownerOf(entries.filter(entry => !entry.ours))
|
|
|
|
if (drop.size === 0) {
|
|
return { changed: false, owner: remaining, text: source }
|
|
}
|
|
|
|
return { changed: true, owner: remaining, text: bom + joinLines(lines.filter((_line, index) => !drop.has(index))) }
|
|
}
|
|
|
|
// ── Preference file ─────────────────────────────────────────────────────────
|
|
|
|
export interface CodexLinkSettings {
|
|
enabled: boolean
|
|
/** Every field of the stored object, so a rewrite keeps what it did not set. */
|
|
stored: Record<string, unknown>
|
|
}
|
|
|
|
/** Missing, unreadable or malformed means ON: the feature is on by default. */
|
|
export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings {
|
|
if (typeof raw !== 'string') {
|
|
return { enabled: true, stored: {} }
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(raw)
|
|
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { enabled: true, stored: {} }
|
|
}
|
|
|
|
return { enabled: parsed.enabled !== false, stored: parsed as Record<string, unknown> }
|
|
} catch {
|
|
return { enabled: true, stored: {} }
|
|
}
|
|
}
|
|
|
|
export function serializeCodexLinkSettings(stored: Record<string, unknown>, enabled: boolean): string {
|
|
const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide'
|
|
|
|
return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n`
|
|
}
|
|
|
|
// ── Bridge configuration ────────────────────────────────────────────────────
|
|
|
|
/** `bridge.json`, rewritten on every apply. */
|
|
export function codexBridgeConfig() {
|
|
return {
|
|
port: CODEX_LINK_PORT,
|
|
chatgpt_backend: 'https://chatgpt.com',
|
|
routes: {},
|
|
nine_router_catalog: { enabled: false },
|
|
pano_kanca_user: false,
|
|
ozet_kopru: { enabled: true, kapsam: 'hepsi', openai_modeli: 'gpt-6-luna', dongu_siniri: 8, dongu_art_arda: 3 }
|
|
}
|
|
}
|
|
|
|
export function serializeCodexBridgeConfig(): string {
|
|
return `${JSON.stringify(codexBridgeConfig(), null, 2)}\n`
|
|
}
|
|
|
|
// ── Bridge command line ─────────────────────────────────────────────────────
|
|
|
|
export interface CodexBridgeLaunch {
|
|
bridgeHome: string
|
|
bridgeScript: string
|
|
hermesHome: string
|
|
}
|
|
|
|
/** Arguments after the interpreter — identical for the spawn and the login item. */
|
|
export function codexBridgeArgs({ bridgeHome, bridgeScript, hermesHome }: CodexBridgeLaunch): string[] {
|
|
return [bridgeScript, '--home', bridgeHome, '--hermes-home', hermesHome]
|
|
}
|
|
|
|
// A trailing backslash would escape the closing quote (`"C:\dir\"`).
|
|
function quoted(value: string): string {
|
|
return `"${value.replace(/[\\/]+$/, '')}"`
|
|
}
|
|
|
|
/** `"<pythonw>" "<bridge.py>" --home "<data>" --hermes-home "<agent>"` */
|
|
export function codexBridgeCommandLine(python: string, launch: CodexBridgeLaunch): string {
|
|
const [script, homeFlag, home, hermesFlag, hermes] = codexBridgeArgs(launch)
|
|
|
|
return `${quoted(python)} ${quoted(script)} ${homeFlag} ${quoted(home)} ${hermesFlag} ${quoted(hermes)}`
|
|
}
|
|
|
|
/**
|
|
* A tiny windowless launcher for the login item, used when the direct command
|
|
* would not fit a Run value. It runs the bridge exactly as the direct command
|
|
* would: same script, same arguments, the script's folder first on sys.path so
|
|
* its helper modules import.
|
|
*/
|
|
export function codexBridgeLauncherSource(launch: CodexBridgeLaunch): string {
|
|
const argv = JSON.stringify(codexBridgeArgs(launch))
|
|
|
|
return [
|
|
'# Written by AITURK IDE (Settings > Codex Baglama). Regenerated on every sync; do not edit.',
|
|
'import os',
|
|
'import runpy',
|
|
'import sys',
|
|
'',
|
|
`ARGV = ${argv}`,
|
|
'',
|
|
'sys.argv = list(ARGV)',
|
|
'sys.path.insert(0, os.path.dirname(ARGV[0]))',
|
|
"runpy.run_path(ARGV[0], run_name='__main__')",
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
export interface CodexAutostartPlan {
|
|
command: string
|
|
/** Launcher file to write first; null when the direct command is used. */
|
|
launcher: { path: string; source: string } | null
|
|
}
|
|
|
|
/**
|
|
* The login-item command. Prefer the direct bridge command; fall back to the
|
|
* launcher when that exceeds the Run-value limit. Returns null when not even
|
|
* the launcher form fits (nothing is registered rather than something that
|
|
* silently never starts).
|
|
*/
|
|
export function codexAutostartPlan(
|
|
python: string,
|
|
launch: CodexBridgeLaunch,
|
|
launcherPath: string
|
|
): CodexAutostartPlan | null {
|
|
const direct = codexBridgeCommandLine(python, launch)
|
|
|
|
if (direct.length <= CODEX_LINK_RUN_COMMAND_MAX) {
|
|
return { command: direct, launcher: null }
|
|
}
|
|
|
|
const viaLauncher = `${quoted(python)} ${quoted(launcherPath)}`
|
|
|
|
if (viaLauncher.length > CODEX_LINK_RUN_COMMAND_MAX) {
|
|
return null
|
|
}
|
|
|
|
return { command: viaLauncher, launcher: { path: launcherPath, source: codexBridgeLauncherSource(launch) } }
|
|
}
|