@gleapai/kai-bridge 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gleapai/kai-bridge",
3
- "version": "0.5.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.73.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
- "yaml": "^2.9.0"
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": "0.3.258"
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
- await this.api.sessionPreview(sessionId, body).catch((err) => this.log("warn", "preview.report.failed", { error: err.message }));
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 the stderr tail. As a command handler
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
- const fail = async (err) => {
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 = resolvePath(String(rawPath || "").replace(/^~(?=$|\/)/, homedir()));
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
- child.kill("SIGTERM");
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
  }