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

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.
@@ -0,0 +1,25 @@
1
+ import { dirname } from 'node:path';
2
+ import { ApplicationIdentityStore } from '../../core/src/application-identities.ts';
3
+ import { nativeClientForAtRoot } from '../../core/src/host-client/index.ts';
4
+
5
+ /** Body-free hook metadata, scoped to this client's adopted identities. */
6
+ export async function readHostHookState({ profile, nativeSessionId, applicationPath, clientFor = nativeClientForAtRoot }) {
7
+ const store = new ApplicationIdentityStore({ instanceId: profile.expectedInstanceId }, { path: applicationPath });
8
+ const identities = await store.list();
9
+ const visible = new Set(identities);
10
+ const client = await clientFor(profile, nativeSessionId, dirname(applicationPath));
11
+ try {
12
+ const [unread, rows] = await Promise.all([client.unread(), client.listIdentities()]);
13
+ const entries = (Array.isArray(unread?.identities) ? unread.identities : []).flatMap(entry => {
14
+ if (!entry || !visible.has(entry.name)) return [];
15
+ const count = Number.isSafeInteger(entry.count) ? entry.count : 0;
16
+ const files = Number.isSafeInteger(entry.files) ? entry.files : 0;
17
+ if (count <= 0 && files <= 0) return [];
18
+ const recent = (Array.isArray(entry.recent) ? entry.recent.slice(-5) : []).flatMap(message =>
19
+ message && typeof message.from === 'string' ? [{ from: message.from, msg_id: String(message.msg_id ?? '?'), date: typeof message.date === 'string' ? message.date : '' }] : []);
20
+ return [{ name: entry.name, count, files, recent }];
21
+ });
22
+ const bindings = (Array.isArray(rows) ? rows : []).flatMap(row => row && visible.has(row.name) && ['mine', 'other-live'].includes(row.session) ? [row.name] : []);
23
+ return { identities, unread: { identities: entries }, bindings };
24
+ } finally { await client.close(); }
25
+ }
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,15 @@ 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
+ for (const key of ['OURS_PORT', 'OURS_API_TOKEN', 'OURS_STATE_DIR', 'OURS_CLIENT_PID', 'OURS_AUTOSTART', 'OURS_DAEMON_ID', 'OURS_DAEMON_URL', 'OURS_DAEMON_CREDENTIAL_PATH']) delete out[key];
39
35
  return out;
40
36
  }
41
37
 
@@ -53,6 +49,12 @@ export function sessionRegistrationFromNotification(message, cwd) {
53
49
  : null;
54
50
  }
55
51
 
52
+ export function sessionEndPayload(profile, state) {
53
+ return profile.profile && typeof state?.threadId === 'string' && state.threadId
54
+ ? `${JSON.stringify({ session_id: state.threadId })}\n`
55
+ : null;
56
+ }
57
+
56
58
  async function freePort() {
57
59
  const server = createServer();
58
60
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
@@ -75,6 +77,16 @@ function waitExit(child) {
75
77
  return once(child, 'exit').then(([code, signal]) => ({ code, signal }));
76
78
  }
77
79
 
80
+ function waitCleanupExit(child) {
81
+ if (child.exitCode != null || child.signalCode != null) return Promise.resolve({ kind: 'exit', code: child.exitCode, signal: child.signalCode });
82
+ return new Promise((resolve) => {
83
+ let settled = false;
84
+ const finish = (outcome) => { if (!settled) { settled = true; resolve(outcome); } };
85
+ child.once('error', (error) => finish({ kind: 'error', error }));
86
+ child.once('exit', (code, signal) => finish({ kind: 'exit', code, signal }));
87
+ });
88
+ }
89
+
78
90
  export async function runLauncher({
79
91
  argv = process.argv.slice(2), env = process.env, platform = process.platform,
80
92
  spawn = nodeSpawn, fetch: fetchImpl = globalThis.fetch,
@@ -96,23 +108,37 @@ export async function runLauncher({
96
108
  let watcher;
97
109
  const sessionEndShim = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'proxy.mjs');
98
110
  const cleanup = async () => {
99
- watcher?.stop();
111
+ await watcher?.stop();
112
+ const retainedState = control?.state;
100
113
  await control?.close().catch(() => {});
101
114
  client?.close();
102
115
  // The TUI exit is an authoritative normal session end. Run the same lease
103
116
  // DELETE seam as the native hook while the shared daemon is still reachable;
104
117
  // duplicate hook+launcher cleanup is intentionally idempotent.
118
+ const input = sessionEndPayload(profile, retainedState);
119
+ if (profile.profile && input === null) {
120
+ if (tui && tui.exitCode == null) tui.kill('SIGTERM');
121
+ if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
122
+ await rm(runtimeDir, { recursive: true, force: true });
123
+ return;
124
+ }
105
125
  const end = spawn(process.execPath, [sessionEndShim, 'session-end'], {
106
126
  env: processEnvs.appServer,
107
- stdio: 'ignore',
127
+ stdio: input === null ? 'ignore' : ['pipe', 'ignore', 'inherit'],
108
128
  });
109
- end.once('error', () => {});
129
+ if (input !== null) end.stdin?.end(input);
110
130
  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');
131
+ const cleanupOutcome = await Promise.race([
132
+ waitCleanupExit(end),
133
+ new Promise((resolve) => { cleanupDeadline = setTimeout(() => resolve({ kind: 'timeout' }), 30_000); }),
134
+ ]).finally(() => clearTimeout(cleanupDeadline));
135
+ if (end.exitCode == null && end.signalCode == null) end.kill('SIGTERM');
136
+ let cleanupFailure;
137
+ if (cleanupOutcome.kind === 'error') cleanupFailure = `spawn error: ${cleanupOutcome.error.message}`;
138
+ else if (cleanupOutcome.kind === 'timeout') cleanupFailure = 'timed out';
139
+ else if (cleanupOutcome.signal) cleanupFailure = `signal ${cleanupOutcome.signal}`;
140
+ else if ((cleanupOutcome.code ?? 0) !== 0) cleanupFailure = `exit ${cleanupOutcome.code}`;
141
+ if (cleanupFailure) process.stderr.write(`ours: SessionEnd cleanup failed: ${cleanupFailure}\n`);
116
142
  if (tui && tui.exitCode == null) tui.kill('SIGTERM');
117
143
  if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
118
144
  await rm(runtimeDir, { recursive: true, force: true });
@@ -129,7 +155,7 @@ export async function runLauncher({
129
155
  throw new Error(`ours monitor cannot answer ${message.method}`);
130
156
  });
131
157
  watcher = new MonitorWatcher({
132
- baseUrl: profile.baseUrl, token: profile.token, fetch: fetchImpl, appServer: client,
158
+ baseUrl: profile.baseUrl, token: profile.token, profile: profile.profile, fetch: fetchImpl, appServer: client,
133
159
  stateStore: new AtomicStateStore(join(runtimeDir, 'cursor.json')),
134
160
  });
135
161
  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,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
+ }
package/src/profile.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { join } from 'node:path';
2
- import { homedir } from 'node:os';
1
+ import { randomUUID } from 'node:crypto';
3
2
 
4
- import { assertDaemonStateDir, resolveDaemonConfig } from '@ours.network/sdk';
3
+ import { attachOursClient } from '@ours.network/sdk';
4
+ import { hostProfileSelectionFromEnv } from '../../core/src/host-client/index.ts';
5
5
 
6
6
  function validPort(value) {
7
7
  const port = Number(value);
@@ -26,41 +26,17 @@ export function parseOursArgs(argv = []) {
26
26
  return { port, codexArgs };
27
27
  }
28
28
 
29
- export async function resolveDaemonProfile({ argv = [], env = process.env, fetch: fetchImpl = globalThis.fetch } = {}) {
29
+ export async function resolveDaemonProfile({ argv = [], env = process.env, fetch: fetchImpl = globalThis.fetch, attach = attachOursClient } = {}) {
30
30
  const parsed = parseOursArgs(argv);
31
- const selection = resolveDaemonConfig({
32
- ...(parsed.port == null ? {} : { port: parsed.port }),
33
- env,
34
- homeDir: env.HOME || homedir(),
35
- });
36
- await assertDaemonStateDir(selection, { fetch: fetchImpl, timeoutMs: 2000 });
37
-
38
- const baseUrl = selection.baseUrl.value;
39
- const token = selection.token?.value ?? null;
40
- const headers = token ? { 'x-ours-api-token': token } : {};
41
- const response = await fetchImpl(`${baseUrl}/info`, { signal: AbortSignal.timeout(2000) });
42
- if (!response.ok) throw new Error(`ours daemon at ${baseUrl} returned HTTP ${response.status}`);
43
- const info = await response.json();
44
- if (info?.name !== 'ours' || !Number.isInteger(info?.protocol) || info.protocol < 1) {
45
- throw new Error(`incompatible service at ${baseUrl}; expected an ours daemon with notification protocol 1`);
46
- }
47
- for (const path of ['/identities', '/unread']) {
48
- const capability = await fetchImpl(`${baseUrl}${path}`, { headers, signal: AbortSignal.timeout(2000) });
49
- if (capability.status === 401 || capability.status === 403) {
50
- throw new Error(`ours daemon authentication failed at ${baseUrl}; supply the matching coherent selection`);
51
- }
52
- if (!capability.ok) throw new Error(`selected daemon lacks ${path} (HTTP ${capability.status})`);
53
- }
54
-
55
- return {
56
- port: selection.port.value,
57
- stateDir: selection.expectStateDir,
58
- token,
59
- visibility: 'owner',
60
- source: selection.baseUrl.source,
61
- info,
62
- baseUrl,
63
- configPath: selection.configPath?.value ?? join(selection.expectStateDir, 'config.json'),
64
- codexArgs: parsed.codexArgs,
65
- };
31
+ const hostSelection = hostProfileSelectionFromEnv(env);
32
+ const { profile: hostProfile, configPath } = hostSelection;
33
+ if (parsed.port != null) throw new Error('ours: --ours-port conflicts with host-profile mode');
34
+ const client = await attach({ ...hostProfile, sessionMode: 'external', leaseToken: randomUUID(), env: {} });
35
+ let info;
36
+ try {
37
+ info = await client.version();
38
+ await client.identities();
39
+ await client.unread();
40
+ } finally { await client.close(); }
41
+ return { profile: hostProfile, info, baseUrl: hostProfile.endpoint, configPath, codexArgs: parsed.codexArgs };
66
42
  }
package/src/watcher.mjs CHANGED
@@ -1,10 +1,15 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { attachOursClient } from '@ours.network/sdk';
3
+
1
4
  export const WAKE_PROMPT = 'New ours mail is available for the identity already bound to this session. Use the ours skill and get_messages now, then handle the unread mail. Do not change identities or reveal message bodies outside the normal get_messages result.';
2
5
 
3
6
  export class MonitorWatcher {
4
- constructor({ baseUrl, token = null, fetch: fetchImpl = globalThis.fetch, appServer, stateStore = { save: async () => {} }, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
7
+ constructor({ baseUrl, token = null, profile = null, clientFactory = attachOursClient, fetch: fetchImpl = globalThis.fetch, appServer, stateStore = { save: async () => {} }, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
5
8
  this.baseUrl = baseUrl;
6
9
  this.headers = token ? { 'x-ours-api-token': token } : {};
7
10
  this.fetch = fetchImpl;
11
+ this.profile = profile;
12
+ this.clientFactory = clientFactory;
8
13
  this.appServer = appServer;
9
14
  this.stateStore = stateStore;
10
15
  this.sleep = sleep;
@@ -23,6 +28,11 @@ export class MonitorWatcher {
23
28
  });
24
29
  }
25
30
 
31
+ async #client() {
32
+ this.clientPromise ??= this.clientFactory({ ...this.profile, sessionMode: 'external', leaseToken: randomUUID(), env: {} });
33
+ return this.clientPromise;
34
+ }
35
+
26
36
  async #json(path, signal) {
27
37
  const response = await this.fetch(`${this.baseUrl}${path}`, { headers: this.headers, signal });
28
38
  if (response.status === 401 || response.status === 403) {
@@ -62,11 +72,24 @@ export class MonitorWatcher {
62
72
 
63
73
  async pollOnce({ identity, threadId, cursor }, signal) {
64
74
  const since = cursor == null ? 'tip' : encodeURIComponent(cursor);
65
- const data = await this.#json(`/identities/${encodeURIComponent(identity)}/notifications?since=${since}`, signal);
75
+ let data;
76
+ let unread;
77
+ try {
78
+ if (this.profile) {
79
+ const client = await this.#client();
80
+ data = await client.readNotificationPage(identity, { since: cursor == null ? 'tip' : Number(cursor), signal });
81
+ if (cursor == null) unread = await client.unread();
82
+ } else {
83
+ data = await this.#json(`/identities/${encodeURIComponent(identity)}/notifications?since=${since}`, signal);
84
+ if (cursor == null) unread = await this.#json('/unread', signal);
85
+ }
86
+ } catch (error) {
87
+ if (error?.status === 401 || error?.status === 403 || /HTTP (401|403)|authentication/i.test(error?.message || '')) this.authFailed = true;
88
+ throw error;
89
+ }
66
90
  const next = { identity, threadId, cursor: String(data.cursor ?? cursor ?? '') };
67
91
  await this.stateStore.save(next);
68
92
  if (cursor == null) {
69
- const unread = await this.#json('/unread', signal);
70
93
  const entry = unread.identities?.find((item) => item.name === identity);
71
94
  if (entry && (Number(entry.count) > 0 || Number(entry.files) > 0)) await this.#wake(threadId);
72
95
  } else if (Array.isArray(data.events) && data.events.length > 0) {
@@ -106,5 +129,8 @@ export class MonitorWatcher {
106
129
  this.controller = null;
107
130
  this.pending = false;
108
131
  this.queued = false;
132
+ const clientPromise = this.clientPromise;
133
+ this.clientPromise = null;
134
+ return clientPromise ? clientPromise.then((client) => client.close()).catch(() => {}) : Promise.resolve();
109
135
  }
110
136
  }