@ours.network/codex 1.1.1 → 1.2.0-nightly.2

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.
@@ -1,10 +1,14 @@
1
1
  #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
2
3
  import { readFile } from 'node:fs/promises';
3
4
  import { homedir } from 'node:os';
4
5
  import { dirname, join, resolve } from 'node:path';
5
6
  import { pathToFileURL } from 'node:url';
6
7
  import { sendControlCommand } from '../control-server.mjs';
7
8
  import { resolveDaemonProfile } from '../profile.mjs';
9
+ import { createHostSessionFactory } from '../network-proxy.mjs';
10
+ import { attachOursClient } from '@ours.network/sdk';
11
+ import { hostProfileFromEnv } from '../../../core/src/host-client/index.ts';
8
12
 
9
13
  async function defaultFindPin(cwd) {
10
14
  let dir = resolve(cwd || process.cwd());
@@ -31,11 +35,13 @@ const safeUnread = (value) => (Array.isArray(value?.identities) ? value.identiti
31
35
  return [{ name: entry.name, count, files, recent }];
32
36
  });
33
37
 
34
- async function applicationIdentityNames(env, stateDir) {
38
+ async function applicationIdentityNames(env, selection) {
35
39
  const path = env.OURS_MCP_CONFIG || join(homedir(), '.ours-mcp', 'config.json');
36
40
  const value = JSON.parse(await readFile(path, 'utf8'));
37
41
  if (value?.version !== 1 || !value.daemons || typeof value.daemons !== 'object') return new Set();
38
- const identities = value.daemons[resolve(stateDir)]?.identities;
42
+ const identities = selection.instanceId
43
+ ? value.instances?.[selection.instanceId]?.identities
44
+ : value.daemons[resolve(selection.stateDir)]?.identities;
39
45
  return new Set(Array.isArray(identities) ? identities.filter((name) => typeof name === 'string') : []);
40
46
  }
41
47
 
@@ -54,7 +60,16 @@ function renderContext(unread, pin) {
54
60
  return lines.join('\n');
55
61
  }
56
62
 
57
- export async function handleHook(payload, { env = process.env, fetch: fetchImpl = globalThis.fetch, send = sendControlCommand, findPin = defaultFindPin } = {}) {
63
+ function applicationIdentitiesFromResource(value) {
64
+ const text = value?.contents?.find((item) => item?.uri === 'ours://application-identities')?.text;
65
+ const parsed = JSON.parse(text);
66
+ if (!Array.isArray(parsed?.identities) || parsed.identities.some((name) => typeof name !== 'string')) {
67
+ throw new Error('invalid application identity resource');
68
+ }
69
+ return new Set(parsed.identities);
70
+ }
71
+
72
+ export async function handleHook(payload, { env = process.env, fetch: fetchImpl = globalThis.fetch, send = sendControlCommand, findPin = defaultFindPin, profileResolver = resolveDaemonProfile, clientFactory = attachOursClient, networkSessionFactory = createHostSessionFactory } = {}) {
58
73
  try {
59
74
  const event = payload?.hook_event_name;
60
75
  const socket = env.OURS_CODEX_CONTROL_SOCKET;
@@ -75,21 +90,53 @@ export async function handleHook(payload, { env = process.env, fetch: fetchImpl
75
90
  let port = env.OURS_PORT || '3050';
76
91
  let selectedStateDir = resolve(env.OURS_STATE_DIR || join(homedir(), '.ours'));
77
92
  let selectedToken = env.OURS_API_TOKEN || '';
93
+ let hostProfile;
94
+ try { hostProfile = hostProfileFromEnv(env); }
95
+ catch { return { continue: true }; }
96
+ if (hostProfile) {
97
+ const nativeSessionId = payload.session_id;
98
+ if (typeof nativeSessionId !== 'string' || !nativeSessionId) return { continue: true };
99
+ const appPath = env.OURS_MCP_CONFIG || join(env.HOME || homedir(), '.ours-mcp', 'config.json');
100
+ let session;
101
+ try {
102
+ const factory = networkSessionFactory({ profile: hostProfile, hostRecordRoot: dirname(appPath), send: async () => {} });
103
+ session = await factory(nativeSessionId);
104
+ const resource = await session.request({ method: 'resources/read', params: { uri: 'ours://application-identities' } });
105
+ const visible = applicationIdentitiesFromResource(resource);
106
+ const unread = safeUnread(await session.fileClient.unread()).filter((entry) => visible.has(entry.name));
107
+ const pin = await findPin(payload.cwd || process.cwd());
108
+ const context = renderContext(unread, pin);
109
+ return context ? { continue: true, hookSpecificOutput: { hookEventName: event, additionalContext: context } } : { continue: true };
110
+ } finally { await session?.close(); }
111
+ }
78
112
  // Standard Codex does not pass through ours-codex's resolved environment.
79
113
  // Resolve the same coherent SDK selection here so SessionStart and
80
114
  // UserPromptSubmit inspect the same daemon as the stdio proxy.
81
115
  try {
82
- const selected = await resolveDaemonProfile({ env, fetch: fetchImpl });
116
+ const selected = await profileResolver({ env, fetch: fetchImpl });
117
+ if (selected.profile) {
118
+ let client;
119
+ try {
120
+ client = await clientFactory({ ...selected.profile, sessionMode: 'external', leaseToken: `codex-hook-${process.pid}`, env: {} });
121
+ const visible = await applicationIdentityNames(env, { instanceId: selected.profile.expectedInstanceId });
122
+ const unread = safeUnread(await client.unread()).filter((entry) => visible.has(entry.name));
123
+ const pin = await findPin(payload.cwd || process.cwd());
124
+ const context = renderContext(unread, pin);
125
+ return context ? { continue: true, hookSpecificOutput: { hookEventName: event, additionalContext: context } } : { continue: true };
126
+ } finally { await client?.close(); }
127
+ }
83
128
  port = String(selected.port);
84
129
  selectedToken = selected.token || '';
85
130
  selectedStateDir = resolve(selected.stateDir);
86
- } catch { /* proxy/launcher owns diagnostics; hooks emit a benign no-op */ }
131
+ } catch {
132
+ /* proxy/launcher owns legacy diagnostics; hooks emit a benign no-op */
133
+ }
87
134
  const headers = selectedToken ? { 'x-ours-api-token': selectedToken } : {};
88
135
  let unread = [];
89
136
  try {
90
137
  const response = await fetchImpl(`http://127.0.0.1:${port}/unread`, { headers, signal: AbortSignal.timeout(1500) });
91
138
  if (response.ok) {
92
- const visible = await applicationIdentityNames(env, selectedStateDir);
139
+ const visible = await applicationIdentityNames(env, { stateDir: selectedStateDir });
93
140
  unread = safeUnread(await response.json()).filter((entry) => visible.has(entry.name));
94
141
  }
95
142
  } catch { /* daemon diagnostics belong to launcher/proxy */ }
@@ -102,7 +149,7 @@ export async function handleHook(payload, { env = process.env, fetch: fetchImpl
102
149
 
103
150
  async function main() {
104
151
  let payload = {};
105
- try { payload = JSON.parse(await readFile(0, 'utf8')); } catch { /* noop */ }
152
+ try { payload = JSON.parse(readFileSync(0, 'utf8')); } catch { /* noop */ }
106
153
  process.stdout.write(JSON.stringify(await handleHook(payload)));
107
154
  }
108
155
 
package/src/launcher.mjs CHANGED
@@ -6,7 +6,7 @@ import { dirname, join } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { randomBytes } from 'node:crypto';
8
8
  import { once } from 'node:events';
9
- import { resolveDaemonProfile } from './profile.mjs';
9
+ import { resolveDaemonProfile } from '../dist/profile.mjs';
10
10
  import { connectAppServer } from './app-server-client.mjs';
11
11
  import { AtomicStateStore, ControlServer } from './control-server.mjs';
12
12
  import { MonitorWatcher } from './watcher.mjs';
@@ -23,19 +23,23 @@ export const remoteTuiArgs = (url, codexArgs) => ['--remote', url, ...codexArgs]
23
23
  export function launcherEnvironment(env, profile, control) {
24
24
  const out = {
25
25
  ...env,
26
- OURS_PORT: String(profile.port),
27
26
  OURS_CONFIG: profile.configPath,
28
- OURS_AUTOSTART: '0',
29
27
  OURS_CODEX_CONTROL_SOCKET: control.socketPath,
30
28
  OURS_CODEX_CAPABILITY: control.capability,
31
29
  OURS_CODEX_LIVE: '1',
32
30
  // One stable owner pid for the MCP proxy, hooks, and launcher's own cleanup
33
31
  // call. The bin shim preserves an explicit value instead of replacing it
34
32
  // with whichever child happened to spawn it.
35
- OURS_CLIENT_PID: String(process.pid),
36
33
  };
37
- if (profile.token) out.OURS_API_TOKEN = profile.token;
38
- else delete out.OURS_API_TOKEN;
34
+ if (profile.profile) {
35
+ for (const key of ['OURS_PORT', 'OURS_API_TOKEN', 'OURS_STATE_DIR', 'OURS_CLIENT_PID', 'OURS_AUTOSTART', 'OURS_DAEMON_ID']) delete out[key];
36
+ } else {
37
+ out.OURS_PORT = String(profile.port);
38
+ out.OURS_AUTOSTART = '0';
39
+ out.OURS_CLIENT_PID = String(process.pid);
40
+ if (profile.token) out.OURS_API_TOKEN = profile.token;
41
+ else delete out.OURS_API_TOKEN;
42
+ }
39
43
  return out;
40
44
  }
41
45
 
@@ -53,6 +57,12 @@ export function sessionRegistrationFromNotification(message, cwd) {
53
57
  : null;
54
58
  }
55
59
 
60
+ export function sessionEndPayload(profile, state) {
61
+ return profile.profile && typeof state?.threadId === 'string' && state.threadId
62
+ ? `${JSON.stringify({ session_id: state.threadId })}\n`
63
+ : null;
64
+ }
65
+
56
66
  async function freePort() {
57
67
  const server = createServer();
58
68
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
@@ -75,6 +85,16 @@ function waitExit(child) {
75
85
  return once(child, 'exit').then(([code, signal]) => ({ code, signal }));
76
86
  }
77
87
 
88
+ function waitCleanupExit(child) {
89
+ if (child.exitCode != null || child.signalCode != null) return Promise.resolve({ kind: 'exit', code: child.exitCode, signal: child.signalCode });
90
+ return new Promise((resolve) => {
91
+ let settled = false;
92
+ const finish = (outcome) => { if (!settled) { settled = true; resolve(outcome); } };
93
+ child.once('error', (error) => finish({ kind: 'error', error }));
94
+ child.once('exit', (code, signal) => finish({ kind: 'exit', code, signal }));
95
+ });
96
+ }
97
+
78
98
  export async function runLauncher({
79
99
  argv = process.argv.slice(2), env = process.env, platform = process.platform,
80
100
  spawn = nodeSpawn, fetch: fetchImpl = globalThis.fetch,
@@ -96,23 +116,37 @@ export async function runLauncher({
96
116
  let watcher;
97
117
  const sessionEndShim = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'proxy.mjs');
98
118
  const cleanup = async () => {
99
- watcher?.stop();
119
+ await watcher?.stop();
120
+ const retainedState = control?.state;
100
121
  await control?.close().catch(() => {});
101
122
  client?.close();
102
123
  // The TUI exit is an authoritative normal session end. Run the same lease
103
124
  // DELETE seam as the native hook while the shared daemon is still reachable;
104
125
  // duplicate hook+launcher cleanup is intentionally idempotent.
126
+ const input = sessionEndPayload(profile, retainedState);
127
+ if (profile.profile && input === null) {
128
+ if (tui && tui.exitCode == null) tui.kill('SIGTERM');
129
+ if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
130
+ await rm(runtimeDir, { recursive: true, force: true });
131
+ return;
132
+ }
105
133
  const end = spawn(process.execPath, [sessionEndShim, 'session-end'], {
106
134
  env: processEnvs.appServer,
107
- stdio: 'ignore',
135
+ stdio: input === null ? 'ignore' : ['pipe', 'ignore', 'inherit'],
108
136
  });
109
- end.once('error', () => {});
137
+ if (input !== null) end.stdin?.end(input);
110
138
  let cleanupDeadline;
111
- await Promise.race([
112
- waitExit(end),
113
- new Promise((resolve) => { cleanupDeadline = setTimeout(resolve, 30_000); }),
114
- ]).catch(() => {}).finally(() => clearTimeout(cleanupDeadline));
115
- if (end.exitCode == null) end.kill('SIGTERM');
139
+ const cleanupOutcome = await Promise.race([
140
+ waitCleanupExit(end),
141
+ new Promise((resolve) => { cleanupDeadline = setTimeout(() => resolve({ kind: 'timeout' }), 30_000); }),
142
+ ]).finally(() => clearTimeout(cleanupDeadline));
143
+ if (end.exitCode == null && end.signalCode == null) end.kill('SIGTERM');
144
+ let cleanupFailure;
145
+ if (cleanupOutcome.kind === 'error') cleanupFailure = `spawn error: ${cleanupOutcome.error.message}`;
146
+ else if (cleanupOutcome.kind === 'timeout') cleanupFailure = 'timed out';
147
+ else if (cleanupOutcome.signal) cleanupFailure = `signal ${cleanupOutcome.signal}`;
148
+ else if ((cleanupOutcome.code ?? 0) !== 0) cleanupFailure = `exit ${cleanupOutcome.code}`;
149
+ if (cleanupFailure) process.stderr.write(`ours: SessionEnd cleanup failed: ${cleanupFailure}\n`);
116
150
  if (tui && tui.exitCode == null) tui.kill('SIGTERM');
117
151
  if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
118
152
  await rm(runtimeDir, { recursive: true, force: true });
@@ -129,7 +163,7 @@ export async function runLauncher({
129
163
  throw new Error(`ours monitor cannot answer ${message.method}`);
130
164
  });
131
165
  watcher = new MonitorWatcher({
132
- baseUrl: profile.baseUrl, token: profile.token, fetch: fetchImpl, appServer: client,
166
+ baseUrl: profile.baseUrl, token: profile.token, profile: profile.profile, fetch: fetchImpl, appServer: client,
133
167
  stateStore: new AtomicStateStore(join(runtimeDir, 'cursor.json')),
134
168
  });
135
169
  control = new ControlServer({
@@ -2,10 +2,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { createRequire } from 'node:module';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { z } from 'zod';
7
+ import { hostProfileFromEnv } from '../../core/src/host-client/index.ts';
6
8
  import { sendControlCommand } from './control-server.mjs';
7
9
 
8
10
  const require = createRequire(import.meta.url);
11
+ const networkWatchPath = fileURLToPath(new URL('../bin/network-watch.mjs', import.meta.url));
9
12
  export const monitorToolNames = ['arm_monitor', 'foreground_monitor', 'disarm_monitor', 'monitor_status'];
10
13
  const content = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
11
14
 
@@ -20,7 +23,16 @@ const foregroundOffer = (identity) =>
20
23
  `Only after an explicit yes, call get_messages once to drain existing unread mail, then ` +
21
24
  `call foreground_monitor({ identity: ${JSON.stringify(identity)} }).`;
22
25
 
23
- export function foregroundWatchProcess(identity) {
26
+ export function foregroundWatchProcess(identity, nativeSessionId, env = process.env) {
27
+ if (hostProfileFromEnv(env)) {
28
+ if (typeof nativeSessionId !== 'string' || !nativeSessionId.trim()) {
29
+ throw new Error('Native session metadata is required for host-profile monitoring.');
30
+ }
31
+ return {
32
+ command: process.execPath,
33
+ args: [networkWatchPath, identity, nativeSessionId],
34
+ };
35
+ }
24
36
  try {
25
37
  const cliPath = require.resolve('@ours.network/mcp/dist/cli.js');
26
38
  return { command: process.execPath, args: [cliPath, 'watch', identity] };
@@ -48,7 +60,7 @@ export async function handleMonitorCommand(command, args = {}, { env = process.e
48
60
  : `Live ours monitor is available but not armed${state.boundIdentity ? `; current binding is "${state.boundIdentity}"` : '; bind an identity first'}.` };
49
61
  }
50
62
 
51
- export function waitForForegroundMail(identity, {
63
+ export function waitForForegroundMail(identity, nativeSessionId, {
52
64
  env = process.env,
53
65
  spawnImpl = spawn,
54
66
  signal,
@@ -57,16 +69,17 @@ export function waitForForegroundMail(identity, {
57
69
  if (typeof identity !== 'string' || !identity.trim()) return Promise.reject(new Error('identity is required'));
58
70
 
59
71
  return new Promise((resolve, reject) => {
60
- const invocation = commandFor(identity);
72
+ const invocation = commandFor(identity, nativeSessionId, env);
61
73
  const child = spawnImpl(invocation.command, invocation.args, {
62
- env,
63
- stdio: ['ignore', 'pipe', 'pipe'],
74
+ env: invocation.env ?? env,
75
+ stdio: [invocation.stdin ?? 'ignore', 'pipe', 'pipe'],
64
76
  });
65
77
  let stdout = '';
66
78
  let stderr = '';
67
79
  let settled = false;
68
80
 
69
81
  const stop = () => {
82
+ if (invocation.stdin === 'pipe') child.stdin?.end();
70
83
  if (!child.killed) child.kill('SIGTERM');
71
84
  };
72
85
  const finish = (fn, value) => {
@@ -109,7 +122,7 @@ export function createMonitorMcpServer(deps = {}) {
109
122
  identity: z.string().min(1).describe('The already-bound ours identity to monitor.'),
110
123
  }, async ({ identity }, extra) => {
111
124
  try {
112
- const event = await waitForForegroundMail(identity, { ...deps, signal: extra?.signal });
125
+ const event = await waitForForegroundMail(identity, extra?._meta?.threadId, { ...deps, signal: extra?.signal });
113
126
  return content(`Foreground ours monitor received a body-free arrival event: ${event}\nCall get_messages now. After handling the mail, call foreground_monitor again without asking if monitoring should remain armed.`);
114
127
  } catch (error) {
115
128
  if (extra?.signal?.aborted) return content('Foreground ours monitor stopped. It is no longer armed.');
@@ -0,0 +1,308 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+
6
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
7
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { ErrorCode, McpError, ResultSchema } from '@modelcontextprotocol/sdk/types.js';
10
+ import {
11
+ completeHostFileCall,
12
+ endNativeSessionAtRoot,
13
+ hostProfileFromEnv,
14
+ nativeClientForAtRoot,
15
+ nativeSessionRecordPath,
16
+ prepareHostFileCall,
17
+ } from '../../core/src/host-client/index.ts';
18
+
19
+ class NetworkTransportError extends Error {
20
+ constructor(cause) {
21
+ super(`Network transport failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
22
+ this.name = 'NetworkTransportError';
23
+ }
24
+ }
25
+
26
+ function isMcpSessionLoss(error) {
27
+ return error instanceof NetworkTransportError
28
+ || error instanceof StreamableHTTPError
29
+ || (error instanceof McpError && error.code === ErrorCode.ConnectionClosed);
30
+ }
31
+
32
+ function threadSelector(frame) {
33
+ const value = frame?.params?._meta?.threadId;
34
+ if (typeof value !== 'string' || !value || value.trim() !== value) {
35
+ throw new Error('Native session metadata is missing or invalid for this host-profile tool call.');
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function defaultSessionSelector(frame) {
41
+ return frame.method === 'tools/call' ? threadSelector(frame) : '__discovery__';
42
+ }
43
+
44
+ export function createNetworkBridge({ sessionFactory, send, selectorFor = defaultSessionSelector }) {
45
+ const sessions = new Map();
46
+ const inFlight = new Map();
47
+ const sessionFor = async (selector) => {
48
+ let promise = sessions.get(selector);
49
+ if (!promise) {
50
+ promise = Promise.resolve(sessionFactory(selector, {
51
+ onClose: () => { if (sessions.get(selector) === promise) sessions.delete(selector); },
52
+ }));
53
+ sessions.set(selector, promise);
54
+ promise.catch(() => { if (sessions.get(selector) === promise) sessions.delete(selector); });
55
+ }
56
+ return promise;
57
+ };
58
+ const evictSession = async (selector, session) => {
59
+ const cached = sessions.get(selector);
60
+ if (cached && await cached.catch(() => undefined) === session && sessions.get(selector) === cached) {
61
+ sessions.delete(selector);
62
+ }
63
+ await session.close().catch(() => {});
64
+ };
65
+ const reply = (id, value, error = false) => send(error
66
+ ? { jsonrpc: '2.0', id, error: { code: -32000, message: value instanceof Error ? value.message : String(value) } }
67
+ : { jsonrpc: '2.0', id, result: value });
68
+ const handle = async (frame) => {
69
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame)) return;
70
+ if (frame.method === 'notifications/initialized') return;
71
+ if (frame.method === 'notifications/cancelled') {
72
+ inFlight.get(frame.params?.requestId)?.abort(frame.params?.reason);
73
+ return;
74
+ }
75
+ if (frame.id === undefined || frame.id === null) {
76
+ const session = await sessionFor(selectorFor(frame));
77
+ await session.notification(frame);
78
+ return;
79
+ }
80
+ const controller = new AbortController();
81
+ inFlight.set(frame.id, controller);
82
+ try {
83
+ const selector = selectorFor(frame);
84
+ const session = await sessionFor(selector);
85
+ controller.signal.throwIfAborted();
86
+ if (frame.method === 'initialize') {
87
+ await reply(frame.id, session.initializeResult);
88
+ return;
89
+ }
90
+ const requestFrame = frame.method === 'tools/call' && session.fileClient
91
+ ? await prepareHostFileCall(session.fileClient, frame, controller.signal)
92
+ : frame;
93
+ controller.signal.throwIfAborted();
94
+ let result;
95
+ try {
96
+ result = await session.request(
97
+ { method: requestFrame.method, params: requestFrame.params ?? {} },
98
+ { signal: controller.signal },
99
+ );
100
+ } catch (error) {
101
+ if (isMcpSessionLoss(error)) await evictSession(selector, session);
102
+ throw error;
103
+ }
104
+ controller.signal.throwIfAborted();
105
+ let response = { jsonrpc: '2.0', id: frame.id, result };
106
+ if (frame.method === 'tools/call' && session.fileClient) {
107
+ response = await completeHostFileCall(session.fileClient, frame, response, controller.signal);
108
+ }
109
+ controller.signal.throwIfAborted();
110
+ await send(response);
111
+ } catch (error) {
112
+ await reply(frame.id, error, true);
113
+ } finally {
114
+ if (inFlight.get(frame.id) === controller) inFlight.delete(frame.id);
115
+ }
116
+ };
117
+ const close = async () => {
118
+ for (const controller of inFlight.values()) controller.abort('stdio closed');
119
+ await Promise.allSettled([...sessions.values()].map(async (session) => (await session).close()));
120
+ sessions.clear();
121
+ };
122
+ return { handle, close };
123
+ }
124
+
125
+ export async function runNetworkProxy({
126
+ env = process.env,
127
+ input = process.stdin,
128
+ stdio = new StdioServerTransport(),
129
+ profile: profileValue,
130
+ hostRecordRoot: hostRecordRootValue,
131
+ sessionFactory: sessionFactoryValue,
132
+ selectorFor,
133
+ installSignalHandlers = true,
134
+ } = {}) {
135
+ const profile = profileValue ?? hostProfileFromEnv(env);
136
+ if (!profile && !sessionFactoryValue) throw new Error('A selected host network profile is required.');
137
+ const configPath = env.OURS_MCP_CONFIG || join(env.HOME || homedir(), '.ours-mcp', 'config.json');
138
+ const hostRecordRoot = hostRecordRootValue ?? dirname(configPath);
139
+ const sessionFactory = sessionFactoryValue ?? createHostSessionFactory({
140
+ profile,
141
+ hostRecordRoot,
142
+ send: (frame) => stdio.send(frame),
143
+ });
144
+ const bridge = createNetworkBridge({ sessionFactory, send: (frame) => stdio.send(frame), selectorFor });
145
+ const tasks = new Set();
146
+ const accept = (raw) => {
147
+ const values = Array.isArray(raw) ? raw : [raw];
148
+ const task = Promise.all(values.map((frame) => bridge.handle(frame)))
149
+ .catch((error) => process.stderr.write(`ours: network proxy failed: ${error.message}\n`))
150
+ .finally(() => tasks.delete(task));
151
+ tasks.add(task);
152
+ };
153
+ stdio.onmessage = accept;
154
+ stdio.onerror = (error) => process.stderr.write(`ours: network stdio failed: ${error.message}\n`);
155
+ await stdio.start();
156
+
157
+ await new Promise((resolve) => {
158
+ let stopped = false;
159
+ const finish = () => {
160
+ if (stopped) return;
161
+ stopped = true;
162
+ input.off('end', finish);
163
+ input.off('close', finish);
164
+ if (installSignalHandlers) {
165
+ process.off('SIGINT', finish);
166
+ process.off('SIGTERM', finish);
167
+ }
168
+ resolve();
169
+ };
170
+ input.once('end', finish);
171
+ input.once('close', finish);
172
+ if (installSignalHandlers) {
173
+ process.once('SIGINT', finish);
174
+ process.once('SIGTERM', finish);
175
+ }
176
+ });
177
+ await bridge.close();
178
+ await Promise.allSettled([...tasks]);
179
+ await stdio.close();
180
+ }
181
+ export { hostProfileFromEnv };
182
+
183
+ export function networkFetchForOwner(profile, ownerInstanceId, { fetchImpl = globalThis.fetch } = {}) {
184
+ return async (input, init = {}) => {
185
+ const token = readFileSync(profile.credentialPath, 'utf8').trim();
186
+ if (!token) throw new Error(`Credential file ${profile.credentialPath} is empty.`);
187
+ const headers = new Headers(init.headers);
188
+ headers.set('x-ours-api-token', token);
189
+ headers.set('x-ours-lease-token', ownerInstanceId);
190
+ headers.set('x-ours-session-mode', 'external');
191
+ try {
192
+ return await fetchImpl(input, { ...init, headers, redirect: 'error' });
193
+ } catch (error) {
194
+ throw new NetworkTransportError(error);
195
+ }
196
+ };
197
+ }
198
+
199
+ export async function createMcpNetworkSession({
200
+ profile,
201
+ ownerInstanceId,
202
+ fileClient,
203
+ onNotification,
204
+ onClose,
205
+ ClientClass = Client,
206
+ TransportClass = StreamableHTTPClientTransport,
207
+ resultSchema = ResultSchema,
208
+ clientInfo = { name: 'ours-codex', version: '2.0.0' },
209
+ }) {
210
+ const client = new ClientClass(clientInfo);
211
+ client.fallbackNotificationHandler = async (notification) => onNotification?.(notification);
212
+ let invalidated = false;
213
+ let closing;
214
+ const evict = () => {
215
+ if (invalidated) return;
216
+ invalidated = true;
217
+ onClose?.();
218
+ };
219
+ const invalidate = async () => {
220
+ evict();
221
+ closing ??= Promise.resolve(client.close()).catch(() => {});
222
+ await closing;
223
+ };
224
+ client.onclose = evict;
225
+ client.onerror = (error) => { if (isMcpSessionLoss(error)) void invalidate(); };
226
+ const ownerHeaders = {
227
+ 'x-ours-lease-token': ownerInstanceId,
228
+ 'x-ours-session-mode': 'external',
229
+ };
230
+ const endpoint = new URL('/mcp', profile.endpoint);
231
+ const transport = new TransportClass(endpoint, {
232
+ fetch: networkFetchForOwner(profile, ownerInstanceId),
233
+ requestInit: { headers: ownerHeaders, redirect: 'error' },
234
+ reconnectionOptions: {
235
+ initialReconnectionDelay: 1000,
236
+ maxReconnectionDelay: 30_000,
237
+ reconnectionDelayGrowFactor: 1.5,
238
+ maxRetries: 5,
239
+ },
240
+ });
241
+ await client.connect(transport);
242
+ const instructions = client.getInstructions?.();
243
+ const initializeResult = {
244
+ protocolVersion: '2025-03-26',
245
+ capabilities: client.getServerCapabilities?.() ?? {},
246
+ serverInfo: client.getServerVersion?.() ?? { name: 'ours', version: 'unknown' },
247
+ ...(instructions ? { instructions } : {}),
248
+ };
249
+ return {
250
+ client,
251
+ fileClient,
252
+ initializeResult,
253
+ request: async (request, options) => {
254
+ try {
255
+ return await client.request(request, resultSchema, options);
256
+ } catch (error) {
257
+ if (isMcpSessionLoss(error)) await invalidate();
258
+ throw error;
259
+ }
260
+ },
261
+ notification: (notification) => client.notification(notification),
262
+ close: invalidate,
263
+ };
264
+ }
265
+
266
+ async function nativeOwnerForAtRoot(profile, nativeSessionId, hostRecordRoot) {
267
+ const path = nativeSessionRecordPath(hostRecordRoot, profile.expectedInstanceId, nativeSessionId);
268
+ const record = JSON.parse(readFileSync(path, 'utf8'));
269
+ if (record?.state !== 'active' || typeof record.ownerInstanceId !== 'string') {
270
+ throw new Error(`Native session record at ${path} is not active.`);
271
+ }
272
+ return record.ownerInstanceId;
273
+ }
274
+
275
+ export function createHostSessionFactory({
276
+ profile,
277
+ hostRecordRoot,
278
+ send,
279
+ nativeClientForImpl = nativeClientForAtRoot,
280
+ ownerForImpl = nativeOwnerForAtRoot,
281
+ createSessionImpl = createMcpNetworkSession,
282
+ }) {
283
+ return async (selector, { onClose } = {}) => {
284
+ const discovery = selector === '__discovery__';
285
+ const fileClient = discovery
286
+ ? undefined
287
+ : await nativeClientForImpl(profile, selector, hostRecordRoot);
288
+ const ownerInstanceId = discovery
289
+ ? randomUUID()
290
+ : await ownerForImpl(profile, selector, hostRecordRoot);
291
+ return createSessionImpl({
292
+ profile,
293
+ ownerInstanceId,
294
+ fileClient,
295
+ onNotification: send,
296
+ onClose,
297
+ });
298
+ };
299
+ }
300
+
301
+ export async function endNetworkNativeSession({
302
+ profile,
303
+ nativeSessionId,
304
+ hostRecordRoot,
305
+ endImpl = endNativeSessionAtRoot,
306
+ }) {
307
+ await endImpl(profile, nativeSessionId, hostRecordRoot);
308
+ }
@@ -0,0 +1,45 @@
1
+ import { homedir } from 'node:os';
2
+ import { dirname, join } from 'node:path';
3
+
4
+ import { hostProfileFromEnv, nativeClientForAtRoot } from '../../core/src/host-client/index.ts';
5
+
6
+ export async function runNetworkWatch({
7
+ identity,
8
+ nativeSessionId,
9
+ env = process.env,
10
+ profile: profileValue,
11
+ hostRecordRoot: hostRecordRootValue,
12
+ clientFor = nativeClientForAtRoot,
13
+ write = (value) => process.stdout.write(value),
14
+ installSignalHandlers = true,
15
+ } = {}) {
16
+ if (typeof identity !== 'string' || !identity.trim()) throw new Error('identity is required');
17
+ if (typeof nativeSessionId !== 'string' || !nativeSessionId.trim()) throw new Error('native session id is required');
18
+ const profile = profileValue ?? hostProfileFromEnv(env);
19
+ if (!profile) throw new Error('A selected host network profile is required.');
20
+ const appPath = env.OURS_MCP_CONFIG || join(env.HOME || homedir(), '.ours-mcp', 'config.json');
21
+ const hostRecordRoot = hostRecordRootValue ?? dirname(appPath);
22
+ const controller = new AbortController();
23
+ const stop = () => controller.abort();
24
+ if (installSignalHandlers) {
25
+ process.once('SIGINT', stop);
26
+ process.once('SIGTERM', stop);
27
+ }
28
+ let client;
29
+ try {
30
+ client = await clientFor(profile, nativeSessionId, hostRecordRoot);
31
+ for await (const event of client.watchNotifications(identity, { kinds: ['inbound'], signal: controller.signal })) {
32
+ const summary = event?.event === 'file_received'
33
+ ? `[${identity}] new file ${event.filename ?? '?'} from ${event.from ?? '?'}`
34
+ : `[${identity}] new message from ${event?.from ?? '?'}`;
35
+ write(`${summary}\n`);
36
+ return;
37
+ }
38
+ } finally {
39
+ if (installSignalHandlers) {
40
+ process.off('SIGINT', stop);
41
+ process.off('SIGTERM', stop);
42
+ }
43
+ await client?.close();
44
+ }
45
+ }