@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.
- package/.codex-plugin/plugin.json +1 -1
- package/.mcp.json +2 -2
- package/README.md +26 -15
- package/bin/codex-config-install.mjs +3 -3
- package/bin/container-file-transfer.mjs +147 -0
- package/bin/network-watch.mjs +8 -0
- package/bin/ours-codex-install.mjs +4 -31
- package/bin/proxy.mjs +25 -20
- package/dist/hooks-runner.mjs +562 -0
- package/dist/host-hooks.mjs +386 -0
- package/dist/monitor-mcp.mjs +32 -7
- package/dist/network-watch.mjs +287 -0
- package/dist/profile.mjs +56 -0
- package/hooks/hooks.json +3 -3
- package/install.sh +14 -33
- package/package.json +5 -5
- package/skills/ours/SKILL.md +8 -7
- package/skills/ours/references/configuration.md +20 -22
- package/src/hooks/runner.mjs +20 -35
- package/src/host-hooks.mjs +25 -0
- package/src/launcher.mjs +41 -15
- package/src/monitor-mcp.mjs +19 -6
- package/src/network-watch.mjs +45 -0
- package/src/profile.mjs +15 -39
- package/src/watcher.mjs +29 -3
|
@@ -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 '
|
|
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
|
-
|
|
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
|
-
|
|
129
|
+
if (input !== null) end.stdin?.end(input);
|
|
110
130
|
let cleanupDeadline;
|
|
111
|
-
await Promise.race([
|
|
112
|
-
|
|
113
|
-
new Promise((resolve) => { cleanupDeadline = setTimeout(resolve, 30_000); }),
|
|
114
|
-
]).
|
|
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({
|
package/src/monitor-mcp.mjs
CHANGED
|
@@ -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 {
|
|
2
|
-
import { homedir } from 'node:os';
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
3
2
|
|
|
4
|
-
import {
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
}
|