@gleapai/kai-bridge 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/fly/Dockerfile +11 -0
- package/fly/UPDATES.md +76 -0
- package/fly/codex-auth.mjs +5 -0
- package/fly/entrypoint.sh +12 -0
- package/fly/login-codex.sh +9 -0
- package/fly/runtime-cli.mjs +15 -0
- package/fly/runtime-cli.sh +10 -0
- package/fly/runtime-launch.mjs +8 -0
- package/fly/runtime-update.mjs +137 -0
- package/fly/start.mjs +7 -0
- package/fly/supervisord.conf +45 -0
- package/npm-shrinkwrap.json +2196 -0
- package/package.json +19 -7
- package/runner/lib/acp/harnesses.mjs +6 -1
- package/scripts/runtime-smoke.mjs +60 -0
- package/src/codex-broker-client.mjs +16 -0
- package/src/codex-broker.mjs +85 -0
- package/src/daemon.mjs +64 -62
- package/src/executor.mjs +22 -4
- package/src/harnesses.mjs +20 -9
- package/src/hosted-auth.mjs +42 -0
- package/src/hosted-git-credential.mjs +24 -0
- package/src/hosted-git.mjs +33 -0
- package/src/hosted-ports.mjs +20 -0
- package/src/hosted-resources.mjs +35 -0
- package/src/hosted-tunnel.mjs +76 -0
- package/src/hosted.mjs +86 -0
- package/src/preview.mjs +45 -9
- package/src/profiles.mjs +5 -2
- package/src/repository-setup.mjs +96 -0
- package/src/workspace.mjs +14 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gleapai/kai-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Run Gleap Kai Code sessions on your own machine with your own Claude Code / Codex login — and preview your real dev servers from the dashboard or the phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,10 @@
|
|
|
12
12
|
"src",
|
|
13
13
|
"runner",
|
|
14
14
|
"scripts/postinstall.mjs",
|
|
15
|
-
"README.md"
|
|
15
|
+
"README.md",
|
|
16
|
+
"fly",
|
|
17
|
+
"scripts/runtime-smoke.mjs",
|
|
18
|
+
"npm-shrinkwrap.json"
|
|
16
19
|
],
|
|
17
20
|
"engines": {
|
|
18
21
|
"node": ">=20"
|
|
@@ -20,19 +23,25 @@
|
|
|
20
23
|
"scripts": {
|
|
21
24
|
"test": "node --test 'test/**/*.test.mjs'",
|
|
22
25
|
"sync-runner": "node scripts/sync-runner.mjs",
|
|
23
|
-
"postinstall": "node scripts/postinstall.mjs"
|
|
26
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
27
|
+
"runtime:check": "node scripts/runtime-smoke.mjs",
|
|
28
|
+
"prepack": "node scripts/prepare-release.mjs",
|
|
29
|
+
"postpack": "node scripts/prepare-release.mjs --clean"
|
|
24
30
|
},
|
|
25
31
|
"dependencies": {
|
|
26
|
-
"@agentclientprotocol/claude-agent-acp": "0.
|
|
32
|
+
"@agentclientprotocol/claude-agent-acp": "0.75.1",
|
|
27
33
|
"@agentclientprotocol/codex-acp": "1.10.0",
|
|
28
34
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
29
35
|
"@playwright/mcp": "^0.0.79",
|
|
30
36
|
"@sockudo/client": "^2.0.0",
|
|
31
37
|
"chokidar": "^4.0.1",
|
|
32
|
-
"
|
|
38
|
+
"ws": "^8.21.3",
|
|
39
|
+
"yaml": "^2.9.0",
|
|
40
|
+
"@openai/codex": "0.153.4",
|
|
41
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.266"
|
|
33
42
|
},
|
|
34
43
|
"overrides": {
|
|
35
|
-
"@anthropic-ai/claude-agent-sdk": "
|
|
44
|
+
"@anthropic-ai/claude-agent-sdk": "$@anthropic-ai/claude-agent-sdk"
|
|
36
45
|
},
|
|
37
46
|
"exports": {
|
|
38
47
|
".": "./src/daemon.mjs",
|
|
@@ -43,5 +52,8 @@
|
|
|
43
52
|
"type": "git",
|
|
44
53
|
"url": "git+https://github.com/GleapSDK/kai-bridge.git"
|
|
45
54
|
},
|
|
46
|
-
"homepage": "https://gleap.io"
|
|
55
|
+
"homepage": "https://gleap.io",
|
|
56
|
+
"kaiHostedRuntime": {
|
|
57
|
+
"protocol": 1
|
|
58
|
+
}
|
|
47
59
|
}
|
|
@@ -339,7 +339,7 @@ export const HARNESSES = {
|
|
|
339
339
|
if (!ctx.byoLogin) env.CODEX_API_KEY = env.OPENAI_API_KEY;
|
|
340
340
|
// MCP servers + disabled/gated tools via config.toml (see
|
|
341
341
|
// buildCodexConfigToml) — never via session/new for codex.
|
|
342
|
-
writeFileSync(join(ctx.configDir, "config.toml"), buildCodexConfigToml(ctx.mcpServers));
|
|
342
|
+
if (process.env.KAI_HOSTED !== '1') writeFileSync(join(ctx.configDir, "config.toml"), buildCodexConfigToml(ctx.mcpServers));
|
|
343
343
|
env.NO_BROWSER = "1";
|
|
344
344
|
env.INITIAL_AGENT_MODE = ctx.isPlanMode ? "read-only" : "agent-full-access";
|
|
345
345
|
// Session config merged by the adapter: model + effort + our
|
|
@@ -349,6 +349,11 @@ export const HARNESSES = {
|
|
|
349
349
|
...(ctx.effort ? { model_reasoning_effort: ctx.effort } : {}),
|
|
350
350
|
...(ctx.instructionsPath ? { model_instructions_file: ctx.instructionsPath } : {}),
|
|
351
351
|
...(ctx.maxContextTokens ? { model_context_window: ctx.maxContextTokens } : {}),
|
|
352
|
+
...(process.env.KAI_HOSTED === '1' ? { features: { apps: false, collaboration_modes: true },
|
|
353
|
+
mcp_servers: Object.fromEntries((ctx.mcpServers || []).filter(s => s && (s.url || s.command)).map(s => [sanitizeMcpKey(s.name || s.id), {
|
|
354
|
+
...(s.transport === 'http' ? { url: s.url, http_headers: s.headers || {} } : { command: s.command, args: s.args || [], env: s.env || {} }),
|
|
355
|
+
startup_timeout_sec: 60, tool_timeout_sec: 60, default_tools_approval_mode: 'approve', disabled_tools: [...(s.disabledTools || []), ...(s.gatedTools || [])],
|
|
356
|
+
}])) } : {}),
|
|
352
357
|
};
|
|
353
358
|
env.CODEX_CONFIG = JSON.stringify(config);
|
|
354
359
|
return env;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join, basename } from 'node:path';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { harnessBinary, harnessAcpCommand } from '../src/harnesses.mjs';
|
|
8
|
+
import { ensureClaudeAcpPatched } from '../src/acp-patch.mjs';
|
|
9
|
+
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
const home = mkdtempSync(join(tmpdir(), 'kai-runtime-check-'));
|
|
12
|
+
// No real login, API key, project, device token or user settings in a smoke test.
|
|
13
|
+
const env = { PATH: process.env.PATH, HOME: home, USERPROFILE: home, CODEX_HOME: join(home, 'codex'), CLAUDE_CONFIG_DIR: join(home, 'claude'), DISABLE_AUTOUPDATER: '1', CI: '1', KAI_RUNTIME_UPDATE_CHECK: '1' };
|
|
14
|
+
mkdirSync(env.CODEX_HOME); mkdirSync(env.CLAUDE_CONFIG_DIR);
|
|
15
|
+
|
|
16
|
+
async function initialize(command, args, params) {
|
|
17
|
+
const child = spawn(command, args, { env, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
18
|
+
const lines = createInterface({ input: child.stdout });
|
|
19
|
+
let diagnostic = '';
|
|
20
|
+
child.stdin.on('error', () => {}); child.stderr.on('data', data => { diagnostic = (diagnostic + data).slice(-1500); });
|
|
21
|
+
let killTimer;
|
|
22
|
+
try {
|
|
23
|
+
await new Promise((resolve, reject) => {
|
|
24
|
+
const timer = setTimeout(() => reject(new Error('Runtime protocol initialization timed out.')), 12_000);
|
|
25
|
+
const finish = error => { clearTimeout(timer); error ? reject(error) : resolve(); };
|
|
26
|
+
child.once('error', finish);
|
|
27
|
+
child.once('exit', (code, signal) => finish(new Error(`${basename(command)} exited before initialization (${code ?? signal}): ${diagnostic}`)));
|
|
28
|
+
lines.on('line', line => {
|
|
29
|
+
let message; try { message = JSON.parse(line); } catch { return; }
|
|
30
|
+
if (message.id === 1) finish(message.error || !message.result ? new Error('Runtime protocol initialization failed.') : null);
|
|
31
|
+
});
|
|
32
|
+
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params }) + '\n');
|
|
33
|
+
});
|
|
34
|
+
} finally {
|
|
35
|
+
lines.close();
|
|
36
|
+
const kill = signal => { try { process.platform === 'win32' ? child.kill(signal) : process.kill(-child.pid, signal); } catch {} };
|
|
37
|
+
kill('SIGTERM');
|
|
38
|
+
await new Promise(resolve => { killTimer = setTimeout(() => { kill('SIGKILL'); resolve(); }, 1500); child.once('close', () => { clearTimeout(killTimer); resolve(); }); });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
ensureClaudeAcpPatched();
|
|
44
|
+
for (const harness of ['claude', 'codex']) {
|
|
45
|
+
const binary = harnessBinary(harness);
|
|
46
|
+
if (!binary) throw new Error(`${harness} executable is missing.`);
|
|
47
|
+
const { stdout } = await exec(binary, ['--version'], { env, timeout: 12_000, maxBuffer: 64 * 1024 });
|
|
48
|
+
if (!/\d+\.\d+\.\d+/.test(stdout)) throw new Error(`${harness} version check failed.`);
|
|
49
|
+
console.log(`${harness}: ${stdout.trim()}`);
|
|
50
|
+
env[harness === 'claude' ? 'CLAUDE_CODE_EXECUTABLE' : 'CODEX_PATH'] = binary;
|
|
51
|
+
}
|
|
52
|
+
await initialize(harnessBinary('codex'), ['app-server'], { clientInfo: { name: 'kai-runtime-check', version: '1.0.0' }, capabilities: {} });
|
|
53
|
+
for (const harness of ['claude', 'codex']) {
|
|
54
|
+
const adapter = harnessAcpCommand(harness);
|
|
55
|
+
await initialize(adapter.cmd, adapter.args, { protocolVersion: 1, clientCapabilities: {}, clientInfo: { name: 'kai-runtime-check', version: '1.0.0' } });
|
|
56
|
+
}
|
|
57
|
+
// Imports catch packaging omissions and syntax/dependency failures before boot.
|
|
58
|
+
await import('../src/daemon.mjs'); await import('../src/codex-broker.mjs');
|
|
59
|
+
console.log('Native CLIs, Codex app-server and both ACP adapters passed isolated startup checks.');
|
|
60
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { connect } from 'node:net';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { harnessBinary } from './harnesses.mjs';
|
|
5
|
+
if (process.argv[2] !== 'app-server') {
|
|
6
|
+
// Version/login commands remain native. Refresh for coding sessions is owned
|
|
7
|
+
// exclusively by the supervised app-server.
|
|
8
|
+
const child = spawn(harnessBinary('codex'), process.argv.slice(2), { stdio: 'inherit' });
|
|
9
|
+
child.on('error', () => process.exit(1));
|
|
10
|
+
child.on('exit', code => process.exit(code || 0));
|
|
11
|
+
} else {
|
|
12
|
+
const socket = connect('/data/home/kai/.kai/codex.sock');
|
|
13
|
+
process.stdin.pipe(socket); socket.pipe(process.stdout);
|
|
14
|
+
socket.on('error', () => { process.stderr.write('The Codex authentication service is unavailable. Restart it using maintenance SSH.\n'); process.exit(1); });
|
|
15
|
+
socket.on('close', () => process.exit(0));
|
|
16
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// One native Codex app-server (and therefore one refresh-token owner) per VM.
|
|
2
|
+
// ACP adapters retain independent session configuration and talk JSON-RPC
|
|
3
|
+
// through private Unix sockets. No authentication files are copied per turn.
|
|
4
|
+
import { createServer } from 'node:net';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
7
|
+
import { chmodSync, rmSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { dirname } from 'node:path';
|
|
9
|
+
import { boundHostedProcess } from './hosted-resources.mjs';
|
|
10
|
+
import { harnessBinary } from './harnesses.mjs';
|
|
11
|
+
|
|
12
|
+
export function startCodexBroker({ socketPath = '/data/home/kai/.kai/codex.sock', command = harnessBinary('codex'), spawnImpl = spawn } = {}) {
|
|
13
|
+
if (!command) throw new Error('The bundled Codex executable is missing.');
|
|
14
|
+
mkdirSync(dirname(socketPath), { recursive: true }); rmSync(socketPath, { force: true });
|
|
15
|
+
const processHandle = spawnImpl(command, ['app-server'], { env: { ...process.env, CODEX_HOME: '/data/home/kai/.codex' }, detached: process.env.KAI_HOSTED === '1', stdio: ['pipe', 'pipe', 'inherit'] });
|
|
16
|
+
boundHostedProcess(processHandle, { limitMb: 1536 });
|
|
17
|
+
const clients = new Set(), pending = new Map(), threadOwners = new Map(), reverse = new Map(), activeTurns = new Map();
|
|
18
|
+
let sequence = 0, initialized, initializeRequest, initializeWaiters = [], notified = false;
|
|
19
|
+
const send = (socket, msg) => { if (!socket.destroyed) socket.write(JSON.stringify(msg) + '\n'); };
|
|
20
|
+
const upstream = msg => processHandle.stdin.write(JSON.stringify(msg) + '\n');
|
|
21
|
+
const server = createServer(socket => {
|
|
22
|
+
clients.add(socket);
|
|
23
|
+
const lines = createInterface({ input: socket });
|
|
24
|
+
lines.on('line', line => {
|
|
25
|
+
let msg; try { msg = JSON.parse(line); } catch { socket.destroy(); return; }
|
|
26
|
+
if (msg.method === 'initialize') {
|
|
27
|
+
if (initialized) { send(socket, { id: msg.id, result: initialized }); return; }
|
|
28
|
+
initializeWaiters.push({ socket, id: msg.id });
|
|
29
|
+
if (!initializeRequest) { initializeRequest = ++sequence; upstream({ ...msg, id: initializeRequest }); }
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (msg.method === 'initialized') { if (!notified) { notified = true; upstream(msg); } return; }
|
|
33
|
+
if (!msg.method && reverse.has(String(msg.id))) {
|
|
34
|
+
const req = reverse.get(String(msg.id));
|
|
35
|
+
if (req.socket !== socket) return;
|
|
36
|
+
reverse.delete(String(msg.id)); upstream({ ...msg, id: req.id }); return;
|
|
37
|
+
}
|
|
38
|
+
const thread = msg.params?.threadId;
|
|
39
|
+
if (thread && threadOwners.has(thread) && threadOwners.get(thread) !== socket && !threadOwners.get(thread).destroyed) {
|
|
40
|
+
if (msg.id != null) send(socket, { id: msg.id, error: { code: -32000, message: 'This thread is active in another session.' } });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (thread) threadOwners.set(thread, socket);
|
|
44
|
+
if (msg.id != null) { const id = ++sequence; pending.set(id, { socket, id: msg.id }); upstream({ ...msg, id }); }
|
|
45
|
+
else upstream(msg);
|
|
46
|
+
});
|
|
47
|
+
socket.once('close', () => {
|
|
48
|
+
clients.delete(socket); lines.close();
|
|
49
|
+
initializeWaiters = initializeWaiters.filter(w => w.socket !== socket);
|
|
50
|
+
for (const [threadId, owner] of threadOwners) if (owner === socket && activeTurns.has(threadId))
|
|
51
|
+
upstream({ id: ++sequence, method: 'turn/interrupt', params: { threadId, turnId: activeTurns.get(threadId) } });
|
|
52
|
+
for (const [key, request] of reverse) if (request.socket === socket) { reverse.delete(key); upstream({ id: request.id, error: { code: -32000, message: 'Session owner disconnected.' } }); }
|
|
53
|
+
});
|
|
54
|
+
socket.on('error', () => socket.destroy());
|
|
55
|
+
});
|
|
56
|
+
createInterface({ input: processHandle.stdout }).on('line', line => {
|
|
57
|
+
let msg; try { msg = JSON.parse(line); } catch { return; }
|
|
58
|
+
if (msg.id === initializeRequest && !msg.method) {
|
|
59
|
+
initialized = msg.result;
|
|
60
|
+
for (const waiter of initializeWaiters) send(waiter.socket, { ...msg, id: waiter.id });
|
|
61
|
+
initializeWaiters = []; if (msg.error) initializeRequest = undefined; return;
|
|
62
|
+
}
|
|
63
|
+
if (msg.id != null && !msg.method) {
|
|
64
|
+
const req = pending.get(msg.id); if (!req) return;
|
|
65
|
+
pending.delete(msg.id);
|
|
66
|
+
const thread = msg.result?.thread?.id;
|
|
67
|
+
if (thread) threadOwners.set(thread, req.socket);
|
|
68
|
+
send(req.socket, { ...msg, id: req.id }); return;
|
|
69
|
+
}
|
|
70
|
+
const thread = msg.params?.threadId || msg.params?.thread_id || msg.params?.thread?.id;
|
|
71
|
+
if (thread && msg.method === 'turn/started' && msg.params?.turn?.id) activeTurns.set(thread, msg.params.turn.id);
|
|
72
|
+
if (thread && msg.method === 'turn/completed') activeTurns.delete(thread);
|
|
73
|
+
const socket = thread && threadOwners.get(thread);
|
|
74
|
+
if (socket && !socket.destroyed) {
|
|
75
|
+
if (msg.id != null) { const id = `up-${++sequence}`; reverse.set(id, { socket, id: msg.id }); send(socket, { ...msg, id }); }
|
|
76
|
+
else send(socket, msg);
|
|
77
|
+
} else if (!thread && msg.id == null && /^(account|model|serverStatus)/.test(msg.method || '')) {
|
|
78
|
+
for (const client of clients) send(client, msg);
|
|
79
|
+
} else if (msg.id != null) upstream({ id: msg.id, error: { code: -32000, message: 'Session owner disconnected.' } });
|
|
80
|
+
});
|
|
81
|
+
processHandle.once('exit', () => { for (const client of clients) client.destroy(); server.close(); });
|
|
82
|
+
processHandle.on('error', () => { for (const client of clients) client.destroy(); server.close(); });
|
|
83
|
+
server.listen(socketPath, () => chmodSync(socketPath, 0o600));
|
|
84
|
+
return { close() { for (const client of clients) client.destroy(); server.close(); processHandle.kill('SIGTERM'); rmSync(socketPath, { force: true }); } };
|
|
85
|
+
}
|
package/src/daemon.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { cloneRepository, locateRepository } from './repository-setup.mjs';
|
|
1
2
|
// The always-on part: one outbound realtime subscription for commands,
|
|
2
3
|
// REST for everything the device reports. Idles at ~0 CPU; reconnects
|
|
3
4
|
// with backoff; keeps the machine awake only while a turn runs.
|
|
@@ -27,6 +28,9 @@ import { BridgeApi, createEventBatcher } from "./api.mjs";
|
|
|
27
28
|
import { ensureClaudeAcpPatched } from "./acp-patch.mjs";
|
|
28
29
|
import { KAI_HOME, defaultConfig, loadConfig, saveConfig } from "./config.mjs";
|
|
29
30
|
import { runTurn } from "./executor.mjs";
|
|
31
|
+
import { awaitHostedAdmission, hostedResources, syncHostedRepositories } from './hosted.mjs';
|
|
32
|
+
import { HostedTunnel } from './hosted-tunnel.mjs';
|
|
33
|
+
import { hostedLoginExpired, markHostedLoginExpired, hostedLoginRecovery } from './hosted-auth.mjs';
|
|
30
34
|
import { createManagedProfile, describeProfiles, managedConfigDir, ambientConfigDir, openLoginTerminal, probeUsageLimits } from "./profiles.mjs";
|
|
31
35
|
import { defaultRoots, groupByRepo, preferredCloneRoot, scanRoots, toDeviceRepoReport } from "./repos.mjs";
|
|
32
36
|
import { describeBranchChanges, discardChanges, collectChanges, commitAndPush, copyPrimaryEnvFiles, currentBranch, currentHead, materializeBinding, sessionSlug, worktreePath, ensureCommitExcludes } from "./workspace.mjs";
|
|
@@ -184,6 +188,14 @@ export class BridgeDaemon {
|
|
|
184
188
|
|
|
185
189
|
async hello() {
|
|
186
190
|
const profiles = await describeProfiles(resolveProfiles(this.config, this.kaiHome), this.usageByProfile);
|
|
191
|
+
if (this.config.hosted) {
|
|
192
|
+
for (const profile of resolveProfiles(this.config, this.kaiHome)) {
|
|
193
|
+
if (hostedLoginExpired(profile, this.kaiHome)) {
|
|
194
|
+
const report = profiles.find(p => p.id === profile.id);
|
|
195
|
+
if (report) report.authState = 'signed_out';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
187
199
|
const repos = toDeviceRepoReport(this.repoGroups);
|
|
188
200
|
return this.api.hello({
|
|
189
201
|
name: this.config.device?.name,
|
|
@@ -260,9 +272,16 @@ export class BridgeDaemon {
|
|
|
260
272
|
// machine stayed offline until the next login — silently.
|
|
261
273
|
this.heartbeat = setInterval(() => {
|
|
262
274
|
this.api
|
|
263
|
-
.heartbeat({ running: [...this.running.keys()] })
|
|
275
|
+
.heartbeat({ running: [...this.running.keys()], ...(this.config.hosted ? { resources: hostedResources(this) } : {}) })
|
|
264
276
|
.catch((err) => this.onApiError("heartbeat", err));
|
|
265
277
|
}, HEARTBEAT_MS);
|
|
278
|
+
if (this.config.hosted) {
|
|
279
|
+
this.hostedTunnel = new HostedTunnel({ config: this.config, api: this.api, log: this.log });
|
|
280
|
+
this.hostedTunnel.connect();
|
|
281
|
+
void this.hostedTunnel.register('maintenance', 'machine-browser', 6080).catch(err => this.log('warn', 'hosted.browser', { error: err.message }));
|
|
282
|
+
const sync = () => void syncHostedRepositories(this).catch(err => this.log('warn', 'hosted.repositories', { error: err.message }));
|
|
283
|
+
sync(); this.hostedRepoTimer = setInterval(sync, 30_000);
|
|
284
|
+
}
|
|
266
285
|
// Plan-usage windows for the composer popover. Fire-and-forget on a
|
|
267
286
|
// slow cadence, unref'd (the heartbeat keeps the process alive), and
|
|
268
287
|
// NEVER in hello's path — the probe spawns the CLI (~2s per profile).
|
|
@@ -390,6 +409,8 @@ export class BridgeDaemon {
|
|
|
390
409
|
let changed = false;
|
|
391
410
|
const profiles = resolveProfiles(this.config, this.kaiHome);
|
|
392
411
|
for (const harness of MODEL_PROBE_HARNESSES) {
|
|
412
|
+
// Model probing must not start a second Codex authentication owner.
|
|
413
|
+
if (this.config.hosted && harness === 'codex') continue;
|
|
393
414
|
// The user's own ~/.claude / ~/.codex first, then any signed-in
|
|
394
415
|
// managed profile — the catalogue is per account, not per profile.
|
|
395
416
|
const candidates = profiles
|
|
@@ -459,6 +480,8 @@ export class BridgeDaemon {
|
|
|
459
480
|
clearInterval(this.usageTimer);
|
|
460
481
|
clearInterval(this.modelsTimer);
|
|
461
482
|
clearInterval(this.updateTimer);
|
|
483
|
+
clearInterval(this.hostedRepoTimer);
|
|
484
|
+
this.hostedTunnel?.close();
|
|
462
485
|
if (this.realtimeRetry) clearTimeout(this.realtimeRetry);
|
|
463
486
|
for (const entry of this.running.values()) entry.ctrl.abort();
|
|
464
487
|
const reports = [];
|
|
@@ -782,6 +805,8 @@ export class BridgeDaemon {
|
|
|
782
805
|
}
|
|
783
806
|
return this.startTurn(data);
|
|
784
807
|
case "bridge.turn.cancel":
|
|
808
|
+
this.hostedCancelled ??= new Set();
|
|
809
|
+
this.hostedCancelled.add(data.turnId);
|
|
785
810
|
this.running.get(data.turnId)?.ctrl.abort();
|
|
786
811
|
return;
|
|
787
812
|
case "bridge.turn.steer":
|
|
@@ -856,6 +881,7 @@ export class BridgeDaemon {
|
|
|
856
881
|
* how non-github companions get cloned; `note` survives onto error writes.
|
|
857
882
|
*/
|
|
858
883
|
async previewStart({ sessionId, title, repos, companionRemotes = null }) {
|
|
884
|
+
if (this.config.hosted) { this.hostedPreviewWork ??= new Set(); this.hostedPreviewWork.add(sessionId); }
|
|
859
885
|
let lastNote = null;
|
|
860
886
|
const skipped = [];
|
|
861
887
|
// Every report is also RETURNED: the verify turn boots the preview
|
|
@@ -863,7 +889,11 @@ export class BridgeDaemon {
|
|
|
863
889
|
const report = async (payload) => {
|
|
864
890
|
if (payload.note) lastNote = payload.note;
|
|
865
891
|
const body = payload.status === "error" ? { ...payload, urls: payload.urls ?? [], previews: payload.previews ?? [], ...(lastNote && !payload.note ? { note: lastNote } : {}), ...(skipped.length && !payload.skipped ? { skipped } : {}) } : payload;
|
|
866
|
-
|
|
892
|
+
if (['running', 'error'].includes(body.status)) this.hostedPreviewWork?.delete(sessionId);
|
|
893
|
+
const runner = this.services.get(sessionId);
|
|
894
|
+
const publicPreview = p => this.config.hosted && runner?.publicUrls?.[p.name] ? { ...p, url: runner.publicUrls[p.name], lanUrl: null } : p;
|
|
895
|
+
const reportBody = this.config.hosted ? { ...body, previews: body.previews?.map(publicPreview), urls: body.urls?.map(publicPreview), landingUrl: body.previews?.[0] ? publicPreview(body.previews[0]).url : body.landingUrl } : body;
|
|
896
|
+
await this.api.sessionPreview(sessionId, reportBody).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
|
|
867
897
|
return body;
|
|
868
898
|
};
|
|
869
899
|
const fail = (err, ctx = {}) => report(toPreviewErrorPayload(err, { ...ctx, skipped }));
|
|
@@ -878,7 +908,7 @@ export class BridgeDaemon {
|
|
|
878
908
|
return fail(new PreviewError(`Repository ${r.key} is not checked out on this device.`, { code: "companion_missing", repo: r.key }), { repo: r.key });
|
|
879
909
|
}
|
|
880
910
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
881
|
-
const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(group.name, sessionId, title);
|
|
911
|
+
const cwd = mode === "local" ? group.primary.path : this.findSessionWorktree(this.config.hosted ? group.key : group.name, sessionId, title);
|
|
882
912
|
if (!cwd || !existsSync(cwd)) {
|
|
883
913
|
return fail(new PreviewError(`The workspace for this session is gone on this device — send Kai a message to recreate it, then start the preview again.`, { code: "other", repo: r.key }), { repo: r.key });
|
|
884
914
|
}
|
|
@@ -1043,6 +1073,7 @@ export class BridgeDaemon {
|
|
|
1043
1073
|
findSessionWorktree(repoName, sessionId, title) {
|
|
1044
1074
|
const exact = worktreePath(this.kaiHome, repoName, sessionSlug(sessionId, title));
|
|
1045
1075
|
if (existsSync(exact)) return exact;
|
|
1076
|
+
if (this.config.hosted) return exact;
|
|
1046
1077
|
const suffix = `-${String(sessionId || "").slice(-8)}`;
|
|
1047
1078
|
if (suffix.length < 2) return exact;
|
|
1048
1079
|
try {
|
|
@@ -1131,7 +1162,7 @@ export class BridgeDaemon {
|
|
|
1131
1162
|
this.previewIdleTimers.delete(sessionId);
|
|
1132
1163
|
const runner = this.services.get(sessionId);
|
|
1133
1164
|
if (!runner) return;
|
|
1134
|
-
if (await this.previewHasConnections(runner)) {
|
|
1165
|
+
if (!this.config.hosted && await this.previewHasConnections(runner)) {
|
|
1135
1166
|
this.log("info", "preview.idle.connected", { sessionId });
|
|
1136
1167
|
if (this.services.get(sessionId) === runner) this.armPreviewIdleTimer(sessionId);
|
|
1137
1168
|
return;
|
|
@@ -1164,6 +1195,7 @@ export class BridgeDaemon {
|
|
|
1164
1195
|
}
|
|
1165
1196
|
|
|
1166
1197
|
async previewStop({ sessionId }) {
|
|
1198
|
+
this.hostedTunnel?.unregisterSession(sessionId);
|
|
1167
1199
|
this.manualPreviews?.delete(sessionId);
|
|
1168
1200
|
this.verifyOwnedPreviews?.delete(sessionId);
|
|
1169
1201
|
// A sign-in window for this session has nothing left to sign in to.
|
|
@@ -1181,6 +1213,7 @@ export class BridgeDaemon {
|
|
|
1181
1213
|
* dashboard why — with the classified log line.
|
|
1182
1214
|
*/
|
|
1183
1215
|
onServiceDied(sessionId, info) {
|
|
1216
|
+
this.hostedTunnel?.unregisterSession(sessionId);
|
|
1184
1217
|
const runner = this.services.get(sessionId);
|
|
1185
1218
|
if (!runner) return Promise.resolve();
|
|
1186
1219
|
this.log("warn", "preview.service.died", { sessionId, name: info.name, code: info.code, errorCode: info.errorCode });
|
|
@@ -1210,6 +1243,7 @@ export class BridgeDaemon {
|
|
|
1210
1243
|
runner = new ServiceRunner({
|
|
1211
1244
|
kaiHome: this.kaiHome,
|
|
1212
1245
|
sessionId,
|
|
1246
|
+
...(this.hostedTunnel ? { registerPublicService: (name, port, protocol) => this.hostedTunnel.register(sessionId, name, port, protocol) } : {}),
|
|
1213
1247
|
log: this.log,
|
|
1214
1248
|
onStatus: (message) => {
|
|
1215
1249
|
this.log("info", "preview.status", { sessionId, message });
|
|
@@ -1235,7 +1269,7 @@ export class BridgeDaemon {
|
|
|
1235
1269
|
const mode = r.mode || this.config.repoModes?.[r.key] || "worktree";
|
|
1236
1270
|
const ws = materializeBinding({
|
|
1237
1271
|
kaiHome: this.kaiHome,
|
|
1238
|
-
repo: { name: group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
1272
|
+
repo: { name: this.config.hosted ? group.key : group.name, primaryPath: group.primary.path, defaultBranch: group.primary.defaultBranch },
|
|
1239
1273
|
binding: { mode, base: r.base, carryUncommitted: r.carryUncommitted },
|
|
1240
1274
|
sessionId: turn.sessionId,
|
|
1241
1275
|
title: turn.title,
|
|
@@ -1269,7 +1303,7 @@ export class BridgeDaemon {
|
|
|
1269
1303
|
);
|
|
1270
1304
|
for (const g of others.slice(0, 40)) {
|
|
1271
1305
|
const base = g.primary.defaultBranch || "main";
|
|
1272
|
-
lines.push(`- ${g.key}: git -C ${g.primary.path} fetch origin ${base} --quiet && git -C ${g.primary.path} worktree add -b kai/${slug} ${worktreePath(this.kaiHome, g.name, slug)} origin/${base}`);
|
|
1306
|
+
lines.push(`- ${g.key}: git -C ${g.primary.path} fetch origin ${base} --quiet && git -C ${g.primary.path} worktree add -b kai/${slug} ${worktreePath(this.kaiHome, this.config.hosted ? g.key : g.name, slug)} origin/${base}`);
|
|
1273
1307
|
}
|
|
1274
1308
|
if (others.length > 40) lines.push(`- (${others.length - 40} more repositories on this device — same recipe, path ~/.kai/worktrees/<repo>/${slug})`);
|
|
1275
1309
|
}
|
|
@@ -1286,7 +1320,7 @@ export class BridgeDaemon {
|
|
|
1286
1320
|
const adopted = [];
|
|
1287
1321
|
for (const g of this.repoGroups) {
|
|
1288
1322
|
if (bound.some((b) => b.key === g.key)) continue;
|
|
1289
|
-
const cwd = worktreePath(this.kaiHome, g.name, slug);
|
|
1323
|
+
const cwd = worktreePath(this.kaiHome, this.config.hosted ? g.key : g.name, slug);
|
|
1290
1324
|
if (!existsSync(cwd)) continue;
|
|
1291
1325
|
const branch = currentBranch(cwd);
|
|
1292
1326
|
if (!branch || branch === "HEAD") continue;
|
|
@@ -1299,6 +1333,8 @@ export class BridgeDaemon {
|
|
|
1299
1333
|
async startTurn(turn) {
|
|
1300
1334
|
const { turnId } = turn;
|
|
1301
1335
|
if (this.running.has(turnId)) return;
|
|
1336
|
+
if (this.config.hosted && !(await awaitHostedAdmission(this, turn))) return;
|
|
1337
|
+
if (this.running.has(turnId)) return;
|
|
1302
1338
|
const ctrl = new AbortController();
|
|
1303
1339
|
const entry = { ctrl, control: null, sessionId: turn.sessionId };
|
|
1304
1340
|
this.running.set(turnId, entry);
|
|
@@ -1313,6 +1349,8 @@ export class BridgeDaemon {
|
|
|
1313
1349
|
const batcher = createEventBatcher({ api: this.api, turnId, onError: (err) => this.log("warn", "events.post.failed", { error: err.message }) });
|
|
1314
1350
|
try {
|
|
1315
1351
|
const profile = resolveProfiles(this.config, this.kaiHome).find((p) => p.id === turn.profileId) ?? { id: "gleap-key", kind: "gleap-key", harness: turn.harness };
|
|
1352
|
+
if (this.config.hosted && (profile.kind === 'gleap-key' || !['codex', 'claude'].includes(profile.harness))) throw new Error('Choose your signed-in Claude Code or Codex profile for this machine.');
|
|
1353
|
+
if (this.config.hosted && hostedLoginExpired(profile, this.kaiHome)) throw new Error(hostedLoginRecovery(profile.harness));
|
|
1316
1354
|
const bound = this.bindRepos(turn);
|
|
1317
1355
|
// Multi-repo: the runner's cwd is the first repo; the others are
|
|
1318
1356
|
// reachable as siblings under the same worktree root or by their
|
|
@@ -1396,6 +1434,11 @@ export class BridgeDaemon {
|
|
|
1396
1434
|
onLog: (l) => this.log("debug", "runner", { line: l.slice(0, 500) }),
|
|
1397
1435
|
});
|
|
1398
1436
|
await batcher.flush();
|
|
1437
|
+
if (this.config.hosted && res.errorCode === 'authentication_required') {
|
|
1438
|
+
markHostedLoginExpired(profile, this.kaiHome);
|
|
1439
|
+
res.lastError = hostedLoginRecovery(profile.harness);
|
|
1440
|
+
await this.hello().catch(() => {});
|
|
1441
|
+
}
|
|
1399
1442
|
const completed = !ctrl.signal.aborted && res.code === 0 && !res.rateLimited;
|
|
1400
1443
|
// Plan and verify turns are read-only: worktrees are restored, local
|
|
1401
1444
|
// checkouts only reported, nothing is ever committed or pushed.
|
|
@@ -1452,6 +1495,7 @@ export class BridgeDaemon {
|
|
|
1452
1495
|
outcome = {
|
|
1453
1496
|
status: ctrl.signal.aborted ? "cancelled" : res.rateLimited ? "rate_limited" : res.code === 0 ? "completed" : "failed",
|
|
1454
1497
|
exitCode: res.code,
|
|
1498
|
+
...(res.errorCode ? { errorCode: res.errorCode } : {}),
|
|
1455
1499
|
result: verify?.redact?.size ? redactDeep(res.result, verify.redact) : res.result,
|
|
1456
1500
|
// The runner's own failure text (e.g. the engine's usage-limit
|
|
1457
1501
|
// message) — the Server prefers this over its generic fallback.
|
|
@@ -2393,65 +2437,27 @@ export class BridgeDaemon {
|
|
|
2393
2437
|
* unattended runs: no credential prompts (`GIT_TERMINAL_PROMPT=0`,
|
|
2394
2438
|
* `GIT_ASKPASS=echo`), stderr captured for the error, bounded by
|
|
2395
2439
|
* `timeoutMs` (default 3 min). Resolves the checkout path; throws an Error
|
|
2396
|
-
* carrying `.target` and
|
|
2440
|
+
* carrying `.target` and a diagnostic message. As a command handler
|
|
2397
2441
|
* (`bridge.repo.clone`) it acks ok/error instead of throwing.
|
|
2398
2442
|
*/
|
|
2399
|
-
async cloneRepo({ commandId, remote, name, timeoutMs = CLONE_TIMEOUT_MS }) {
|
|
2400
|
-
// Prefer the directory the machine's repos already live in (e.g.
|
|
2401
|
-
// ~/Documents/Gleap), not the bare scan root that discovered them
|
|
2402
|
-
// (~/Documents) — "Clone here" should land next to the other checkouts.
|
|
2443
|
+
async cloneRepo({ commandId, remote, name, repoKey, timeoutMs = CLONE_TIMEOUT_MS }) {
|
|
2403
2444
|
const root = preferredCloneRoot(this.repoGroups, (this.config.roots || [])[0] ?? defaultRoots()[0]);
|
|
2404
|
-
|
|
2445
|
+
try {
|
|
2446
|
+
const target = await cloneRepository({ remote, name, root, repoKey, timeoutMs });
|
|
2447
|
+
await this.scanRepos();
|
|
2448
|
+
// Ack only after the Server has the new inventory: recovery checks
|
|
2449
|
+
// readiness before automatically retrying the blocked session.
|
|
2450
|
+
await this.hello();
|
|
2451
|
+
if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
|
|
2452
|
+
return target;
|
|
2453
|
+
} catch (err) {
|
|
2454
|
+
if (err.target) err.cloneCommand = buildCloneCommand({ remote, target: err.target });
|
|
2405
2455
|
if (commandId) {
|
|
2406
2456
|
await this.api.commandAck(commandId, { ok: false, error: err.message, ...(err.cloneCommand ? { cloneCommand: err.cloneCommand } : {}) }).catch(() => {});
|
|
2407
2457
|
return null;
|
|
2408
2458
|
}
|
|
2409
2459
|
throw err;
|
|
2410
|
-
};
|
|
2411
|
-
if (!root) return fail(new Error("No scan root to clone into — add one with `kai-bridge repo roots add <dir>`."));
|
|
2412
|
-
const target = join(root, String(name || "").replace(/[^\w.-]/g, "-") || "repo");
|
|
2413
|
-
if (existsSync(target)) {
|
|
2414
|
-
const err = new Error(`${target} already exists — remove it or point Gleap at it (Locate…).`);
|
|
2415
|
-
err.target = target;
|
|
2416
|
-
return fail(err);
|
|
2417
2460
|
}
|
|
2418
|
-
try {
|
|
2419
|
-
await new Promise((resolve, reject) => {
|
|
2420
|
-
const p = spawn("git", ["clone", remote, target], {
|
|
2421
|
-
stdio: ["ignore", "ignore", "pipe"],
|
|
2422
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "echo", SSH_ASKPASS: "echo", GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND || "ssh -o BatchMode=yes" },
|
|
2423
|
-
});
|
|
2424
|
-
let stderr = "";
|
|
2425
|
-
p.stderr.on("data", (d) => {
|
|
2426
|
-
stderr = `${stderr}${d}`.slice(-4000);
|
|
2427
|
-
});
|
|
2428
|
-
const timer = setTimeout(() => {
|
|
2429
|
-
try {
|
|
2430
|
-
p.kill("SIGTERM");
|
|
2431
|
-
} catch {}
|
|
2432
|
-
reject(Object.assign(new Error(`git clone timed out after ${Math.round(timeoutMs / 60_000)} minutes`), { target, stderr }));
|
|
2433
|
-
}, timeoutMs);
|
|
2434
|
-
p.on("close", (code) => {
|
|
2435
|
-
clearTimeout(timer);
|
|
2436
|
-
if (code === 0) return resolve();
|
|
2437
|
-
const tail = stderr.trim().split("\n").filter(Boolean).slice(-2).join(" · ").slice(0, 300);
|
|
2438
|
-
reject(Object.assign(new Error(`git clone exited ${code}${tail ? ` — ${tail}` : ""}`), { target, stderr, code }));
|
|
2439
|
-
});
|
|
2440
|
-
p.on("error", (err) => {
|
|
2441
|
-
clearTimeout(timer);
|
|
2442
|
-
reject(Object.assign(err, { target }));
|
|
2443
|
-
});
|
|
2444
|
-
});
|
|
2445
|
-
} catch (err) {
|
|
2446
|
-
this.log("error", "repo.clone.failed", { remote, target, error: err.message });
|
|
2447
|
-
rmSync(target, { recursive: true, force: true });
|
|
2448
|
-
err.cloneCommand = buildCloneCommand({ remote, target });
|
|
2449
|
-
return fail(err);
|
|
2450
|
-
}
|
|
2451
|
-
await this.scanRepos();
|
|
2452
|
-
await this.hello().catch(() => {});
|
|
2453
|
-
if (commandId) await this.api.commandAck(commandId, { ok: true, path: target });
|
|
2454
|
-
return target;
|
|
2455
2461
|
}
|
|
2456
2462
|
|
|
2457
2463
|
/**
|
|
@@ -2466,11 +2472,7 @@ export class BridgeDaemon {
|
|
|
2466
2472
|
*/
|
|
2467
2473
|
async locateRepo({ commandId, repoKey, path: rawPath }) {
|
|
2468
2474
|
try {
|
|
2469
|
-
const target =
|
|
2470
|
-
if (!existsSync(target)) throw new Error(`${target} does not exist on this machine.`);
|
|
2471
|
-
if (repoKey && !existsSync(join(target, ".git"))) {
|
|
2472
|
-
throw new Error(`${target} is not a git checkout.`);
|
|
2473
|
-
}
|
|
2475
|
+
const target = locateRepository(rawPath, repoKey);
|
|
2474
2476
|
const root = repoKey ? dirname(target) : target;
|
|
2475
2477
|
this.config.roots = [...new Set([...(this.config.roots || []), root])];
|
|
2476
2478
|
if (repoKey) {
|
package/src/executor.mjs
CHANGED
|
@@ -13,8 +13,10 @@ import { delimiter, dirname, join } from "node:path";
|
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
|
|
15
15
|
import { ambientConfigDir, managedConfigDir } from "./profiles.mjs";
|
|
16
|
-
import { harnessAcpCommand } from "./harnesses.mjs";
|
|
16
|
+
import { harnessAcpCommand, harnessBinary } from "./harnesses.mjs";
|
|
17
17
|
import { KAI_HOME } from "./config.mjs";
|
|
18
|
+
import { boundHostedProcess } from './hosted-resources.mjs';
|
|
19
|
+
import { classifyHarnessFailure } from './hosted-auth.mjs';
|
|
18
20
|
|
|
19
21
|
const RUNNER = join(dirname(fileURLToPath(import.meta.url)), "..", "runner", "acp-runner.mjs");
|
|
20
22
|
const b64 = (v) => Buffer.from(typeof v === "string" ? v : JSON.stringify(v), "utf8").toString("base64");
|
|
@@ -77,6 +79,13 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
77
79
|
for (const [k, v] of Object.entries(extraEnv || {})) if (v != null && v !== "") env[k] = String(v);
|
|
78
80
|
// Never leak the Gleap device token into the harness.
|
|
79
81
|
delete env.KAI_DEVICE_TOKEN;
|
|
82
|
+
delete env.KAI_BOOTSTRAP_TOKEN;
|
|
83
|
+
// Login, version probes and coding must resolve the same release's binaries.
|
|
84
|
+
// Hosted Codex replaces this path below with its single-owner RPC transport.
|
|
85
|
+
const native = harnessBinary(profile.harness, kaiHome);
|
|
86
|
+
if (native && profile.harness === 'claude') { env.CLAUDE_CODE_EXECUTABLE = native; env.DISABLE_AUTOUPDATER = '1'; }
|
|
87
|
+
if (native && profile.harness === 'codex') env.CODEX_PATH = native;
|
|
88
|
+
if (process.env.KAI_HOSTED === '1') env.NODE_OPTIONS = '--max-old-space-size=1536';
|
|
80
89
|
// The daemon's own node first on PATH for the runner, the harness and
|
|
81
90
|
// every shell the agent opens. Under launchd PATH is frozen at install
|
|
82
91
|
// time and a stale /usr/local/bin/node (v20 here) beat the nvm node the
|
|
@@ -98,6 +107,12 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
98
107
|
env.KAI_ACP_CONFIG_DIR = join(kaiHome, "state", profile.id, "cursor");
|
|
99
108
|
mkdirSync(env.KAI_ACP_CONFIG_DIR, { recursive: true });
|
|
100
109
|
} else if (profile.harness === "codex") {
|
|
110
|
+
if (process.env.KAI_HOSTED === '1') {
|
|
111
|
+
// A single supervised app-server owns login refresh for every hosted
|
|
112
|
+
// turn. Per-session config travels over RPC, never through config.toml.
|
|
113
|
+
env.KAI_ACP_CONFIG_DIR = profileDir;
|
|
114
|
+
env.CODEX_PATH = join(dirname(fileURLToPath(import.meta.url)), 'codex-broker-client.mjs');
|
|
115
|
+
} else {
|
|
101
116
|
// The runner writes config.toml (MCP servers, kill-switches) into
|
|
102
117
|
// CODEX_HOME — that must never be the user's real ~/.codex. Use a
|
|
103
118
|
// per-profile session dir seeded with a COPY of auth.json (read-only
|
|
@@ -107,6 +122,7 @@ export function buildRunnerEnv(turn, profile, kaiHome = KAI_HOME, extraEnv = nul
|
|
|
107
122
|
const src = join(profileDir, "auth.json");
|
|
108
123
|
if (existsSync(src)) copyFileSync(src, join(sessionDir, "auth.json"));
|
|
109
124
|
env.KAI_ACP_CONFIG_DIR = sessionDir;
|
|
125
|
+
}
|
|
110
126
|
} else {
|
|
111
127
|
// Claude: CLAUDE_CONFIG_DIR IS the login dir; the runner reads the
|
|
112
128
|
// transcript from it and passes settings via the SDK, never by file.
|
|
@@ -143,7 +159,8 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
143
159
|
// stdin is the runner's control channel (JSONL: steer / cancel) — see
|
|
144
160
|
// startControlChannel in runner/acp-runner.mjs. Never closed from
|
|
145
161
|
// here; the runner exits on its own when the turn ends.
|
|
146
|
-
const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, stdio: ["pipe", "pipe", "pipe"] });
|
|
162
|
+
const child = spawn(process.execPath, [RUNNER, ...args], { cwd: workDir, env, detached: process.env.KAI_HOSTED === '1', stdio: ["pipe", "pipe", "pipe"] });
|
|
163
|
+
boundHostedProcess(child, { onExceeded: message => { lastError = message; onEvent({ type: 'error', message, code: 'memory_pressure' }); } });
|
|
147
164
|
child.stdin.on("error", () => {});
|
|
148
165
|
onSpawn?.({
|
|
149
166
|
/** Write one control line; false when the runner is already gone. */
|
|
@@ -182,7 +199,8 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
182
199
|
child.stderr.on("data", (d) => onLog(String(d)));
|
|
183
200
|
const onAbort = () => {
|
|
184
201
|
try {
|
|
185
|
-
|
|
202
|
+
if (process.env.KAI_HOSTED === '1') process.kill(-child.pid, 'SIGTERM');
|
|
203
|
+
else child.kill("SIGTERM");
|
|
186
204
|
} catch {
|
|
187
205
|
/* gone */
|
|
188
206
|
}
|
|
@@ -190,7 +208,7 @@ export function runTurn({ turn, profile, workDir, onEvent, onLog = () => {}, onS
|
|
|
190
208
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
191
209
|
child.on("close", (code) => {
|
|
192
210
|
signal?.removeEventListener("abort", onAbort);
|
|
193
|
-
resolve({ code, result, rateLimited, lastError });
|
|
211
|
+
resolve({ code, result, rateLimited, lastError, errorCode: classifyHarnessFailure(lastError || '') });
|
|
194
212
|
});
|
|
195
213
|
});
|
|
196
214
|
}
|