@ours.network/codex 0.9.1 → 0.10.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/.codex-plugin/plugin.json +30 -0
- package/.mcp.json +17 -0
- package/AGENTS.snippet.md +11 -16
- package/LICENSE +98 -0
- package/README.md +89 -131
- package/bin/codex-legacy-cleanup.mjs +54 -0
- package/bin/monitor-mcp.mjs +4 -0
- package/bin/ours-codex-install.mjs +7 -14
- package/bin/ours-codex.mjs +9 -0
- package/bin/proxy.mjs +31 -0
- package/hooks/hooks.json +40 -0
- package/install.sh +33 -11
- package/package.json +16 -4
- package/skills/ours/SKILL.md +62 -50
- package/skills/ours/references/configuration.md +20 -31
- package/skills/writing-agent-bios/SKILL.md +0 -1
- package/src/app-server-client.mjs +111 -0
- package/src/control-protocol.mjs +21 -0
- package/src/control-server.mjs +91 -0
- package/src/hooks/runner.mjs +86 -0
- package/src/launcher.mjs +145 -0
- package/src/monitor-mcp.mjs +133 -0
- package/src/monitor-state.mjs +55 -0
- package/src/profile.mjs +78 -0
- package/src/watcher.mjs +110 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { sendControlCommand } from '../control-server.mjs';
|
|
6
|
+
|
|
7
|
+
async function defaultFindPin(cwd) {
|
|
8
|
+
let dir = resolve(cwd || process.cwd());
|
|
9
|
+
for (;;) {
|
|
10
|
+
try {
|
|
11
|
+
const value = JSON.parse(await readFile(join(dir, '.ours-identity'), 'utf8'));
|
|
12
|
+
if (typeof value.identity === 'string' && value.identity.trim()) return value;
|
|
13
|
+
return null;
|
|
14
|
+
} catch { /* walk */ }
|
|
15
|
+
const parent = dirname(dir);
|
|
16
|
+
if (parent === dir) return null;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const safeUnread = (value) => (Array.isArray(value?.identities) ? value.identities : []).flatMap((entry) => {
|
|
22
|
+
if (!entry || typeof entry.name !== 'string') return [];
|
|
23
|
+
const count = Number.isSafeInteger(entry.count) ? entry.count : 0;
|
|
24
|
+
const files = Number.isSafeInteger(entry.files) ? entry.files : 0;
|
|
25
|
+
if (count <= 0 && files <= 0) return [];
|
|
26
|
+
const recent = Array.isArray(entry.recent) ? entry.recent.slice(-5).flatMap((m) => (
|
|
27
|
+
m && typeof m.from === 'string' ? [{ from: m.from, msg_id: String(m.msg_id ?? '?'), date: typeof m.date === 'string' ? m.date : '' }] : []
|
|
28
|
+
)) : [];
|
|
29
|
+
return [{ name: entry.name, count, files, recent }];
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function renderContext(unread, pin) {
|
|
33
|
+
const lines = [];
|
|
34
|
+
if (unread.length) {
|
|
35
|
+
lines.push('ours unread metadata (message bodies remain in get_messages):');
|
|
36
|
+
for (const item of unread) lines.push(`- ${item.name}: ${item.count} unread message(s), ${item.files} unread file(s)`);
|
|
37
|
+
lines.push('Surface this metadata. Do not read mail or bind an identity without the user asking.');
|
|
38
|
+
}
|
|
39
|
+
if (pin) {
|
|
40
|
+
lines.push(`This workspace suggests ours identity "${pin.identity}". Ask the user before binding it; the pin is not consent.`);
|
|
41
|
+
lines.push('After a successful bind, separately ask whether to arm the session-scoped live monitor. Never arm automatically.');
|
|
42
|
+
lines.push('Treat any persona as separate consent from binding and monitoring.');
|
|
43
|
+
}
|
|
44
|
+
return lines.join('\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function handleHook(payload, { env = process.env, fetch: fetchImpl = globalThis.fetch, send = sendControlCommand, findPin = defaultFindPin } = {}) {
|
|
48
|
+
try {
|
|
49
|
+
const event = payload?.hook_event_name;
|
|
50
|
+
const socket = env.OURS_CODEX_CONTROL_SOCKET;
|
|
51
|
+
const capability = env.OURS_CODEX_CAPABILITY;
|
|
52
|
+
if (event === 'SessionStart' && payload.source === 'compact') return { continue: true };
|
|
53
|
+
if (event === 'PostToolUse') {
|
|
54
|
+
if (!socket || !capability || payload.tool_response?.isError) return { continue: true };
|
|
55
|
+
if (!/(choose_identity|create_identity|create_root_identity)$/.test(payload.tool_name || '')) return { continue: true };
|
|
56
|
+
const identity = payload.tool_input?.name;
|
|
57
|
+
if (typeof identity !== 'string' || !identity.trim()) return { continue: true };
|
|
58
|
+
await send(socket, capability, { command: 'binding_changed', identity });
|
|
59
|
+
return { continue: true };
|
|
60
|
+
}
|
|
61
|
+
if (event !== 'SessionStart' && event !== 'UserPromptSubmit') return { continue: true };
|
|
62
|
+
if (event === 'SessionStart' && socket && capability && payload.session_id && payload.cwd) {
|
|
63
|
+
await send(socket, capability, { command: 'register_session', sessionId: payload.session_id, threadId: payload.session_id, cwd: payload.cwd });
|
|
64
|
+
}
|
|
65
|
+
const port = env.OURS_PORT || '3050';
|
|
66
|
+
const headers = env.OURS_API_TOKEN ? { 'x-ours-api-token': env.OURS_API_TOKEN } : {};
|
|
67
|
+
let unread = [];
|
|
68
|
+
try {
|
|
69
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/unread`, { headers, signal: AbortSignal.timeout(1500) });
|
|
70
|
+
if (response.ok) unread = safeUnread(await response.json());
|
|
71
|
+
} catch { /* daemon diagnostics belong to launcher/proxy */ }
|
|
72
|
+
const pin = await findPin(payload.cwd || process.cwd());
|
|
73
|
+
const context = renderContext(unread, pin);
|
|
74
|
+
if (!context) return { continue: true };
|
|
75
|
+
return { continue: true, hookSpecificOutput: { hookEventName: event, additionalContext: context } };
|
|
76
|
+
} catch { return { continue: true }; }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main() {
|
|
80
|
+
let payload = {};
|
|
81
|
+
try { payload = JSON.parse(await readFile(0, 'utf8')); } catch { /* noop */ }
|
|
82
|
+
process.stdout.write(JSON.stringify(await handleHook(payload)));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) main();
|
|
86
|
+
|
package/src/launcher.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { chmod, mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { once } from 'node:events';
|
|
8
|
+
import { resolveDaemonProfile } from './profile.mjs';
|
|
9
|
+
import { connectAppServer } from './app-server-client.mjs';
|
|
10
|
+
import { AtomicStateStore, ControlServer } from './control-server.mjs';
|
|
11
|
+
import { MonitorWatcher } from './watcher.mjs';
|
|
12
|
+
|
|
13
|
+
export function validatePlatform(platform = process.platform) {
|
|
14
|
+
if (platform === 'win32') throw new Error('native Windows is not supported in ours-codex v1; run it inside WSL');
|
|
15
|
+
}
|
|
16
|
+
export const appServerArgs = (url, codexArgs = []) => [
|
|
17
|
+
...(codexArgs.includes('--dangerously-bypass-hook-trust') ? ['--dangerously-bypass-hook-trust'] : []),
|
|
18
|
+
'app-server', '--listen', url,
|
|
19
|
+
];
|
|
20
|
+
export const remoteTuiArgs = (url, codexArgs) => ['--remote', url, ...codexArgs];
|
|
21
|
+
|
|
22
|
+
export function launcherEnvironment(env, profile, control) {
|
|
23
|
+
const out = {
|
|
24
|
+
...env,
|
|
25
|
+
OURS_PORT: String(profile.port),
|
|
26
|
+
OURS_CONFIG: profile.configPath,
|
|
27
|
+
OURS_AUTOSTART: '0',
|
|
28
|
+
OURS_CODEX_CONTROL_SOCKET: control.socketPath,
|
|
29
|
+
OURS_CODEX_CAPABILITY: control.capability,
|
|
30
|
+
OURS_CODEX_LIVE: '1',
|
|
31
|
+
};
|
|
32
|
+
if (profile.token) out.OURS_API_TOKEN = profile.token;
|
|
33
|
+
else delete out.OURS_API_TOKEN;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function liveProcessEnvironments(env, profile, control) {
|
|
38
|
+
const live = launcherEnvironment(env, profile, control);
|
|
39
|
+
return { appServer: live, tui: live };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sessionRegistrationFromNotification(message, cwd) {
|
|
43
|
+
const threadId = message?.method === 'thread/started' ? message.params?.thread?.id
|
|
44
|
+
: message?.method === 'turn/started' ? (message.params?.threadId || message.params?.thread?.id)
|
|
45
|
+
: null;
|
|
46
|
+
return typeof threadId === 'string' && threadId
|
|
47
|
+
? { command: 'register_session', sessionId: threadId, threadId, cwd }
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function freePort() {
|
|
52
|
+
const server = createServer();
|
|
53
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
54
|
+
const port = server.address().port;
|
|
55
|
+
await new Promise((resolve) => server.close(resolve));
|
|
56
|
+
return port;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function waitReady(url, fetchImpl, child) {
|
|
60
|
+
for (let attempt = 0; attempt < 80; attempt += 1) {
|
|
61
|
+
if (child.exitCode != null) throw new Error(`Codex App Server exited early (${child.exitCode})`);
|
|
62
|
+
try { if ((await fetchImpl(`${url.replace(/^ws/, 'http')}/readyz`)).ok) return; } catch { /* startup */ }
|
|
63
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
64
|
+
}
|
|
65
|
+
throw new Error('Codex App Server did not become ready');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function waitExit(child) {
|
|
69
|
+
if (child.exitCode != null) return Promise.resolve({ code: child.exitCode, signal: child.signalCode });
|
|
70
|
+
return once(child, 'exit').then(([code, signal]) => ({ code, signal }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runLauncher({
|
|
74
|
+
argv = process.argv.slice(2), env = process.env, platform = process.platform,
|
|
75
|
+
spawn = nodeSpawn, fetch: fetchImpl = globalThis.fetch,
|
|
76
|
+
profileResolver = resolveDaemonProfile, connect = connectAppServer,
|
|
77
|
+
} = {}) {
|
|
78
|
+
validatePlatform(platform);
|
|
79
|
+
const profile = await profileResolver({ argv, env, fetch: fetchImpl });
|
|
80
|
+
const runtimeBase = env.XDG_RUNTIME_DIR || tmpdir();
|
|
81
|
+
const runtimeDir = await mkdtemp(join(runtimeBase, 'ours-codex-'));
|
|
82
|
+
await chmod(runtimeDir, 0o700);
|
|
83
|
+
const socketPath = join(runtimeDir, 'control.sock');
|
|
84
|
+
const capability = randomBytes(32).toString('hex');
|
|
85
|
+
const url = `ws://127.0.0.1:${await freePort()}`;
|
|
86
|
+
const processEnvs = liveProcessEnvironments(env, profile, { socketPath, capability });
|
|
87
|
+
let appServer;
|
|
88
|
+
let tui;
|
|
89
|
+
let client;
|
|
90
|
+
let control;
|
|
91
|
+
let watcher;
|
|
92
|
+
const cleanup = async () => {
|
|
93
|
+
watcher?.stop();
|
|
94
|
+
await control?.close().catch(() => {});
|
|
95
|
+
client?.close();
|
|
96
|
+
if (tui && tui.exitCode == null) tui.kill('SIGTERM');
|
|
97
|
+
if (appServer && appServer.exitCode == null) appServer.kill('SIGTERM');
|
|
98
|
+
await rm(runtimeDir, { recursive: true, force: true });
|
|
99
|
+
};
|
|
100
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
101
|
+
const handlers = new Map();
|
|
102
|
+
try {
|
|
103
|
+
appServer = spawn('codex', appServerArgs(url, profile.codexArgs), { env: processEnvs.appServer, stdio: ['ignore', 'ignore', 'inherit'] });
|
|
104
|
+
appServer.once('error', () => {});
|
|
105
|
+
await waitReady(url, fetchImpl, appServer);
|
|
106
|
+
client = await connect(url, { timeoutMs: 30_000 });
|
|
107
|
+
client.onServerRequest((message) => {
|
|
108
|
+
if (message.method.includes('requestApproval')) return { decision: 'decline' };
|
|
109
|
+
throw new Error(`ours monitor cannot answer ${message.method}`);
|
|
110
|
+
});
|
|
111
|
+
watcher = new MonitorWatcher({
|
|
112
|
+
baseUrl: profile.baseUrl, token: profile.token, fetch: fetchImpl, appServer: client,
|
|
113
|
+
stateStore: new AtomicStateStore(join(runtimeDir, 'cursor.json')),
|
|
114
|
+
});
|
|
115
|
+
control = new ControlServer({
|
|
116
|
+
socketPath, capability, stateStore: new AtomicStateStore(join(runtimeDir, 'state.json')),
|
|
117
|
+
onEffects: async (effects, state) => {
|
|
118
|
+
for (const effect of effects) {
|
|
119
|
+
if (effect.type === 'unsubscribe') watcher.stop();
|
|
120
|
+
if (effect.type === 'subscribe') {
|
|
121
|
+
if (!state.threadId) throw new Error('Codex session has not registered its thread yet');
|
|
122
|
+
void watcher.start({ identity: effect.identity, threadId: state.threadId, cursor: state.cursor }).catch(() => {});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
await control.start();
|
|
128
|
+
client.onNotification((message) => {
|
|
129
|
+
const registration = sessionRegistrationFromNotification(message, profile.cwd || process.cwd());
|
|
130
|
+
if (registration) void control.apply(registration);
|
|
131
|
+
});
|
|
132
|
+
tui = spawn('codex', remoteTuiArgs(url, profile.codexArgs), { env: processEnvs.tui, stdio: 'inherit' });
|
|
133
|
+
for (const signal of signals) {
|
|
134
|
+
const handler = () => { if (tui?.exitCode == null) tui.kill(signal); };
|
|
135
|
+
handlers.set(signal, handler); process.on(signal, handler);
|
|
136
|
+
}
|
|
137
|
+
const outcome = await waitExit(tui);
|
|
138
|
+
return outcome.signal ? 128 : (outcome.code ?? 0);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
throw new Error(`${error.message}\nStandard mode remains available with: codex`);
|
|
141
|
+
} finally {
|
|
142
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
143
|
+
await cleanup();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { sendControlCommand } from './control-server.mjs';
|
|
7
|
+
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
export const monitorToolNames = ['arm_monitor', 'foreground_monitor', 'disarm_monitor', 'monitor_status'];
|
|
10
|
+
const content = (text, isError = false) => ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
|
|
11
|
+
|
|
12
|
+
const foregroundOffer = (identity) =>
|
|
13
|
+
'IMPORTANT: Before offering the fallback, tell the user exactly: ' +
|
|
14
|
+
'"This standard `codex` session only supports a blocking foreground monitor. ' +
|
|
15
|
+
'For background monitoring, restart the session with `ours-codex` instead; it adds ' +
|
|
16
|
+
'the App Server integration needed for session-scoped background wake." ' +
|
|
17
|
+
'A blocking foreground monitor is available here, but while it waits this Codex session ' +
|
|
18
|
+
'cannot accept another prompt (press Escape to stop it). ' +
|
|
19
|
+
`Ask the user: "Do you want to arm the foreground blocking monitor here?" ` +
|
|
20
|
+
`Only after an explicit yes, call get_messages once to drain existing unread mail, then ` +
|
|
21
|
+
`call foreground_monitor({ identity: ${JSON.stringify(identity)} }).`;
|
|
22
|
+
|
|
23
|
+
function foregroundWatchProcess(identity) {
|
|
24
|
+
try {
|
|
25
|
+
const cliPath = require.resolve('@ours.network/mcp/dist/cli.js');
|
|
26
|
+
return { command: process.execPath, args: [cliPath, 'watch', identity] };
|
|
27
|
+
} catch {
|
|
28
|
+
return { command: 'ours-mcp', args: ['watch', identity] };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function handleMonitorCommand(command, args = {}, { env = process.env, send = sendControlCommand } = {}) {
|
|
33
|
+
const socket = env.OURS_CODEX_CONTROL_SOCKET;
|
|
34
|
+
const capability = env.OURS_CODEX_CAPABILITY;
|
|
35
|
+
if (!socket || !capability) {
|
|
36
|
+
if (command === 'arm') return { text: foregroundOffer(args.identity), mode: 'foreground-offer' };
|
|
37
|
+
if (command === 'disarm') return { text: 'No background monitor is armed. A blocking foreground monitor stops when its tool call is interrupted.' };
|
|
38
|
+
return { text: 'Background mail monitoring is unavailable in this standard Codex session. Use `ours-codex` for background wake, or explicitly arm the blocking foreground monitor for the bound identity.' };
|
|
39
|
+
}
|
|
40
|
+
const wire = command === 'arm' ? { command: 'arm', identity: args.identity }
|
|
41
|
+
: command === 'disarm' ? { command: 'disarm' } : { command: 'status' };
|
|
42
|
+
const response = await send(socket, capability, wire);
|
|
43
|
+
const state = response.state;
|
|
44
|
+
if (command === 'arm') return { text: `Live ours monitor armed for "${state.armedIdentity}". It stops when this Codex CLI session exits.` };
|
|
45
|
+
if (command === 'disarm') return { text: 'Live ours monitor disarmed.' };
|
|
46
|
+
return { text: state.armedIdentity
|
|
47
|
+
? `Live ours monitor is armed for "${state.armedIdentity}"${state.turnActive ? '; a turn is active' : ''}.`
|
|
48
|
+
: `Live ours monitor is available but not armed${state.boundIdentity ? `; current binding is "${state.boundIdentity}"` : '; bind an identity first'}.` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function waitForForegroundMail(identity, {
|
|
52
|
+
env = process.env,
|
|
53
|
+
spawnImpl = spawn,
|
|
54
|
+
signal,
|
|
55
|
+
commandFor = foregroundWatchProcess,
|
|
56
|
+
} = {}) {
|
|
57
|
+
if (typeof identity !== 'string' || !identity.trim()) return Promise.reject(new Error('identity is required'));
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const invocation = commandFor(identity);
|
|
61
|
+
const child = spawnImpl(invocation.command, invocation.args, {
|
|
62
|
+
env,
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
});
|
|
65
|
+
let stdout = '';
|
|
66
|
+
let stderr = '';
|
|
67
|
+
let settled = false;
|
|
68
|
+
|
|
69
|
+
const stop = () => {
|
|
70
|
+
if (!child.killed) child.kill('SIGTERM');
|
|
71
|
+
};
|
|
72
|
+
const finish = (fn, value) => {
|
|
73
|
+
if (settled) return;
|
|
74
|
+
settled = true;
|
|
75
|
+
signal?.removeEventListener('abort', onAbort);
|
|
76
|
+
stop();
|
|
77
|
+
fn(value);
|
|
78
|
+
};
|
|
79
|
+
const onAbort = () => finish(reject, new Error('foreground monitor stopped'));
|
|
80
|
+
|
|
81
|
+
if (signal?.aborted) return onAbort();
|
|
82
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
83
|
+
child.once('error', (error) => finish(reject, error));
|
|
84
|
+
child.stderr?.on('data', (chunk) => { stderr += String(chunk); });
|
|
85
|
+
child.stdout?.on('data', (chunk) => {
|
|
86
|
+
stdout += String(chunk);
|
|
87
|
+
const newline = stdout.indexOf('\n');
|
|
88
|
+
if (newline === -1) return;
|
|
89
|
+
const event = stdout.slice(0, newline).trim();
|
|
90
|
+
if (event) finish(resolve, event);
|
|
91
|
+
});
|
|
92
|
+
child.once('exit', (code, childSignal) => {
|
|
93
|
+
if (settled) return;
|
|
94
|
+
const detail = stderr.trim() || `exit ${code ?? childSignal ?? 'unknown'}`;
|
|
95
|
+
finish(reject, new Error(`ours-mcp watch stopped before mail arrived: ${detail}`));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function createMonitorMcpServer(deps = {}) {
|
|
101
|
+
const server = new McpServer({ name: 'ours-monitor', version: '0.9.1' });
|
|
102
|
+
server.tool('arm_monitor', 'Arm session-scoped live mail wake for the currently bound ours identity. Call only after explicit user consent. If standard mode is reported, relay its ours-codex background-monitor recommendation to the user verbatim before offering the foreground fallback.', {
|
|
103
|
+
identity: z.string().min(1).describe('The already-bound ours identity to monitor.'),
|
|
104
|
+
}, async ({ identity }) => {
|
|
105
|
+
try { return content((await handleMonitorCommand('arm', { identity }, deps)).text); }
|
|
106
|
+
catch (error) { return content(`Could not arm live monitor: ${error.message}`, true); }
|
|
107
|
+
});
|
|
108
|
+
server.tool('foreground_monitor', 'Block this Codex turn until new mail arrives for the bound ours identity. Never call unless the user was first told that ours-codex provides background monitoring and then explicitly accepted this foreground fallback.', {
|
|
109
|
+
identity: z.string().min(1).describe('The already-bound ours identity to monitor.'),
|
|
110
|
+
}, async ({ identity }, extra) => {
|
|
111
|
+
try {
|
|
112
|
+
const event = await waitForForegroundMail(identity, { ...deps, signal: extra?.signal });
|
|
113
|
+
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
|
+
} catch (error) {
|
|
115
|
+
if (extra?.signal?.aborted) return content('Foreground ours monitor stopped. It is no longer armed.');
|
|
116
|
+
return content(`Foreground ours monitor failed: ${error.message}`, true);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
server.tool('disarm_monitor', 'Stop this session’s live ours mail wake monitor.', {}, async () => {
|
|
120
|
+
try { return content((await handleMonitorCommand('disarm', {}, deps)).text); }
|
|
121
|
+
catch (error) { return content(`Could not disarm live monitor: ${error.message}`, true); }
|
|
122
|
+
});
|
|
123
|
+
server.tool('monitor_status', 'Report whether live ours mail wake is available and armed.', {}, async () => {
|
|
124
|
+
try { return content((await handleMonitorCommand('status', {}, deps)).text); }
|
|
125
|
+
catch (error) { return content(`Could not read monitor status: ${error.message}`, true); }
|
|
126
|
+
});
|
|
127
|
+
return server;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function runMonitorMcp() {
|
|
131
|
+
const server = createMonitorMcpServer();
|
|
132
|
+
await server.connect(new StdioServerTransport());
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export function createMonitorState(overrides = {}) {
|
|
2
|
+
return {
|
|
3
|
+
sessionId: null, threadId: null, cwd: null,
|
|
4
|
+
boundIdentity: null, armedIdentity: null,
|
|
5
|
+
cursor: null, pendingWake: false, turnActive: false, lastError: null,
|
|
6
|
+
...overrides,
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const result = (state, effects = []) => ({ state, effects });
|
|
11
|
+
|
|
12
|
+
export function registerSession(state, { sessionId, threadId, cwd }) {
|
|
13
|
+
return result({ ...state, sessionId, threadId, cwd });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function bindingChanged(state, identity) {
|
|
17
|
+
const effects = [];
|
|
18
|
+
if (state.armedIdentity && state.armedIdentity !== identity) effects.push({ type: 'unsubscribe', identity: state.armedIdentity });
|
|
19
|
+
return result({ ...state, boundIdentity: identity, armedIdentity: state.armedIdentity === identity ? identity : null, cursor: state.armedIdentity === identity ? state.cursor : null, pendingWake: false }, effects);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function arm(state, identity) {
|
|
23
|
+
if (!identity) throw new Error('cannot arm an empty identity');
|
|
24
|
+
if (state.boundIdentity && state.boundIdentity !== identity) throw new Error(`cannot arm ${identity}; current binding is ${state.boundIdentity}`);
|
|
25
|
+
if (state.armedIdentity === identity) return result(state);
|
|
26
|
+
// The arm tool itself is an explicit, identity-named consent boundary. Claiming an
|
|
27
|
+
// otherwise-unset binding here keeps live wake functional before plugin hooks have
|
|
28
|
+
// been reviewed; trusted PostToolUse hooks still synchronize later identity switches.
|
|
29
|
+
return result({ ...state, boundIdentity: identity, armedIdentity: identity, cursor: null, pendingWake: false, lastError: null }, [{ type: 'subscribe', identity }]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function disarm(state) {
|
|
33
|
+
const effects = state.armedIdentity ? [{ type: 'unsubscribe', identity: state.armedIdentity }] : [];
|
|
34
|
+
return result({ ...state, armedIdentity: null, cursor: null, pendingWake: false }, effects);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function notificationArrived(state, cursor) {
|
|
38
|
+
if (!state.armedIdentity) return result({ ...state, cursor });
|
|
39
|
+
if (state.pendingWake) return result({ ...state, cursor });
|
|
40
|
+
if (state.turnActive) return result({ ...state, cursor, pendingWake: true });
|
|
41
|
+
return result({ ...state, cursor, pendingWake: true }, [{ type: 'startWakeTurn' }]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function turnStarted(state) {
|
|
45
|
+
return result({ ...state, turnActive: true, pendingWake: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function turnCompleted(state) {
|
|
49
|
+
if (state.pendingWake && state.armedIdentity) return result({ ...state, turnActive: false }, [{ type: 'startWakeTurn' }]);
|
|
50
|
+
return result({ ...state, turnActive: false, pendingWake: false });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function withError(state, error) {
|
|
54
|
+
return result({ ...state, lastError: String(error) });
|
|
55
|
+
}
|
package/src/profile.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_PORT = 3050;
|
|
6
|
+
|
|
7
|
+
function validPort(value) {
|
|
8
|
+
const port = Number(value);
|
|
9
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`ours: ${value} is not a valid TCP port`);
|
|
10
|
+
return port;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseOursArgs(argv = []) {
|
|
14
|
+
const codexArgs = [];
|
|
15
|
+
let port;
|
|
16
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
17
|
+
const arg = argv[i];
|
|
18
|
+
if (arg === '--ours-port') {
|
|
19
|
+
if (argv[i + 1] == null) throw new Error('ours: --ours-port requires a value');
|
|
20
|
+
port = validPort(argv[++i]);
|
|
21
|
+
} else if (arg.startsWith('--ours-port=')) {
|
|
22
|
+
port = validPort(arg.slice('--ours-port='.length));
|
|
23
|
+
} else {
|
|
24
|
+
codexArgs.push(arg);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return { port, codexArgs };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readJson(path) {
|
|
31
|
+
try { return JSON.parse(await readFile(path, 'utf8')); } catch { return {}; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readOwnerToken(stateDir) {
|
|
35
|
+
try { return (await readFile(join(stateDir, 'daemon-token'), 'utf8')).trim() || null; } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function resolveDaemonProfile({ argv = [], env = process.env, readConfig, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
39
|
+
const parsed = parseOursArgs(argv);
|
|
40
|
+
const configPath = env.OURS_CONFIG || join(env.HOME || homedir(), '.ours', 'config.json');
|
|
41
|
+
const config = await (readConfig ? readConfig(configPath) : readJson(configPath));
|
|
42
|
+
let source = 'default';
|
|
43
|
+
let port = DEFAULT_PORT;
|
|
44
|
+
if (config?.port != null) { port = validPort(config.port); source = env.OURS_CONFIG ? 'OURS_CONFIG' : 'config'; }
|
|
45
|
+
if (env.OURS_PORT != null && env.OURS_PORT !== '') { port = validPort(env.OURS_PORT); source = 'OURS_PORT'; }
|
|
46
|
+
if (parsed.port != null) { port = parsed.port; source = '--ours-port'; }
|
|
47
|
+
|
|
48
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
49
|
+
let response;
|
|
50
|
+
try { response = await fetchImpl(`${baseUrl}/info`, { signal: AbortSignal.timeout(2000) }); }
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw new Error(`ours daemon on port ${port} is not reachable; ours-codex never starts it. Start the selected daemon first. (${error?.message || error})`);
|
|
53
|
+
}
|
|
54
|
+
if (!response.ok) throw new Error(`ours daemon on port ${port} returned HTTP ${response.status}`);
|
|
55
|
+
const info = await response.json();
|
|
56
|
+
if (info?.name !== 'ours' || !Number.isInteger(info?.protocol) || info.protocol < 1 || typeof info?.stateDir !== 'string') {
|
|
57
|
+
throw new Error(`incompatible service on port ${port}; expected an ours daemon with notification protocol 1`);
|
|
58
|
+
}
|
|
59
|
+
const stateDir = resolve(info.stateDir);
|
|
60
|
+
const token = env.OURS_API_TOKEN?.trim() || config?.apiToken?.trim() || await readOwnerToken(stateDir);
|
|
61
|
+
const headers = token ? { 'x-ours-api-token': token } : {};
|
|
62
|
+
let capability;
|
|
63
|
+
try { capability = await fetchImpl(`${baseUrl}/identities`, { headers, signal: AbortSignal.timeout(2000) }); }
|
|
64
|
+
catch (error) { throw new Error(`ours daemon capability check failed: ${error?.message || error}`); }
|
|
65
|
+
if (capability.status === 401 || capability.status === 403) throw new Error(`ours daemon authentication failed on port ${port}; supply the matching OURS_API_TOKEN/config`);
|
|
66
|
+
if (!capability.ok) throw new Error(`selected daemon lacks the notification API (HTTP ${capability.status})`);
|
|
67
|
+
let unread;
|
|
68
|
+
try { unread = await fetchImpl(`${baseUrl}/unread`, { headers, signal: AbortSignal.timeout(2000) }); }
|
|
69
|
+
catch (error) { throw new Error(`ours daemon unread capability check failed: ${error?.message || error}`); }
|
|
70
|
+
if (unread.status === 401 || unread.status === 403) throw new Error(`ours daemon authentication failed on port ${port}; supply the matching OURS_API_TOKEN/config`);
|
|
71
|
+
if (!unread.ok) throw new Error(`selected daemon lacks the body-free unread API (HTTP ${unread.status}); install the testing daemon build and restart that daemon explicitly`);
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
port, stateDir, token: token || null,
|
|
75
|
+
visibility: env.OURS_API_VISIBILITY || config?.apiVisibility || 'owner',
|
|
76
|
+
source, info, baseUrl, configPath, codexArgs: parsed.codexArgs,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/watcher.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
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
|
+
|
|
3
|
+
export class MonitorWatcher {
|
|
4
|
+
constructor({ baseUrl, token = null, fetch: fetchImpl = globalThis.fetch, appServer, stateStore = { save: async () => {} }, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) }) {
|
|
5
|
+
this.baseUrl = baseUrl;
|
|
6
|
+
this.headers = token ? { 'x-ours-api-token': token } : {};
|
|
7
|
+
this.fetch = fetchImpl;
|
|
8
|
+
this.appServer = appServer;
|
|
9
|
+
this.stateStore = stateStore;
|
|
10
|
+
this.sleep = sleep;
|
|
11
|
+
this.authFailed = false;
|
|
12
|
+
this.running = false;
|
|
13
|
+
this.turnActive = false;
|
|
14
|
+
this.pending = false;
|
|
15
|
+
this.queued = false;
|
|
16
|
+
this.controller = null;
|
|
17
|
+
appServer?.onNotification?.((message) => {
|
|
18
|
+
if (message.method === 'turn/started') { this.turnActive = true; this.pending = false; }
|
|
19
|
+
if (message.method === 'turn/completed') {
|
|
20
|
+
this.turnActive = false;
|
|
21
|
+
if (this.pending && this.current) void this.#wake(this.current.threadId);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async #json(path, signal) {
|
|
27
|
+
const response = await this.fetch(`${this.baseUrl}${path}`, { headers: this.headers, signal });
|
|
28
|
+
if (response.status === 401 || response.status === 403) {
|
|
29
|
+
this.authFailed = true;
|
|
30
|
+
throw new Error('ours daemon authentication failed; monitor disarmed');
|
|
31
|
+
}
|
|
32
|
+
if (!response.ok) throw new Error(`ours daemon returned HTTP ${response.status}`);
|
|
33
|
+
return response.json();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async #wake(threadId) {
|
|
37
|
+
if (this.turnActive || this.pending) { this.queued = true; return; }
|
|
38
|
+
this.pending = true;
|
|
39
|
+
try {
|
|
40
|
+
const result = await this.appServer.startTurn(threadId, WAKE_PROMPT);
|
|
41
|
+
if (this.appServer.readThread && result?.turn?.id) void this.#trackCompletion(threadId, result.turn.id);
|
|
42
|
+
else this.pending = false;
|
|
43
|
+
}
|
|
44
|
+
catch (error) { this.pending = false; throw error; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async #trackCompletion(threadId, turnId) {
|
|
48
|
+
try {
|
|
49
|
+
while (this.pending) {
|
|
50
|
+
const result = await this.appServer.readThread(threadId);
|
|
51
|
+
const turn = result?.thread?.turns?.find((item) => item.id === turnId);
|
|
52
|
+
if (turn && turn.status !== 'inProgress') break;
|
|
53
|
+
await this.sleep(250);
|
|
54
|
+
}
|
|
55
|
+
} catch { /* the next notification poll remains recoverable */ }
|
|
56
|
+
this.pending = false;
|
|
57
|
+
if (this.queued && this.current) {
|
|
58
|
+
this.queued = false;
|
|
59
|
+
void this.#wake(this.current.threadId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async pollOnce({ identity, threadId, cursor }, signal) {
|
|
64
|
+
const since = cursor == null ? 'tip' : encodeURIComponent(cursor);
|
|
65
|
+
const data = await this.#json(`/identities/${encodeURIComponent(identity)}/notifications?since=${since}`, signal);
|
|
66
|
+
const next = { identity, threadId, cursor: String(data.cursor ?? cursor ?? '') };
|
|
67
|
+
await this.stateStore.save(next);
|
|
68
|
+
if (cursor == null) {
|
|
69
|
+
const unread = await this.#json('/unread', signal);
|
|
70
|
+
const entry = unread.identities?.find((item) => item.name === identity);
|
|
71
|
+
if (entry && (Number(entry.count) > 0 || Number(entry.files) > 0)) await this.#wake(threadId);
|
|
72
|
+
} else if (Array.isArray(data.events) && data.events.length > 0) {
|
|
73
|
+
await this.#wake(threadId);
|
|
74
|
+
}
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
start({ identity, threadId, cursor = null }) {
|
|
79
|
+
this.stop();
|
|
80
|
+
this.current = { identity, threadId, cursor };
|
|
81
|
+
this.running = true;
|
|
82
|
+
this.authFailed = false;
|
|
83
|
+
this.controller = new AbortController();
|
|
84
|
+
this.loopPromise = this.#loop(this.controller.signal);
|
|
85
|
+
return this.loopPromise;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async #loop(signal) {
|
|
89
|
+
let delay = 250;
|
|
90
|
+
while (this.running && !signal.aborted) {
|
|
91
|
+
try {
|
|
92
|
+
this.current = await this.pollOnce(this.current, signal);
|
|
93
|
+
delay = 250;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (signal.aborted || !this.running) break;
|
|
96
|
+
if (this.authFailed) { this.running = false; throw error; }
|
|
97
|
+
await this.sleep(delay);
|
|
98
|
+
delay = Math.min(delay * 2, 10_000);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
stop() {
|
|
104
|
+
this.running = false;
|
|
105
|
+
this.controller?.abort();
|
|
106
|
+
this.controller = null;
|
|
107
|
+
this.pending = false;
|
|
108
|
+
this.queued = false;
|
|
109
|
+
}
|
|
110
|
+
}
|